From 08f8dfe09a0f85f863381425cdf955374a4e78f7 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] 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 --- .codex/config.toml | 2 + .config/dotnet-tools.json | 20 + .editorconfig | 673 + .gitattributes | 13 + .github/ISSUE_TEMPLATE/bug_report.md | 28 - .github/ISSUE_TEMPLATE/feature_request.md | 20 - .github/dependabot.yml | 50 + .github/workflows/ci.yml | 126 + .github/workflows/dependency-audit.yml | 58 + .gitignore | 411 +- .mcp.json | 8 + AGENTS.md | 62 + CONTRIBUTING.md | 125 + Claude.md | 1 + Directory.Build.props | 63 + Directory.Build.targets | 25 + Directory.Packages.props | 29 + GenLauncher.sln | 31 - GenLauncherGO.Core/AGENTS.md | 12 + GenLauncherGO.Core/GenLauncherGO.Core.csproj | 5 + GenLauncherGO.Core/IO/LexicalPath.cs | 214 + .../Integrity/Models/ContentIntegrityIssue.cs | 11 + .../Models/ContentIntegrityReport.cs | 25 + .../Models/ContentIntegrityTarget.cs | 50 + .../Integrity/Models/ContentSourceKind.cs | 41 + .../Integrity/Models/IntegrityIssueAction.cs | 37 + .../Integrity/Models/IntegrityIssueKind.cs | 42 + .../IGameExecutableDiscoveryService.cs | 25 + .../Contracts/IGameProcessLaunchOperation.cs | 30 + .../Contracts/IGameProcessLauncher.cs | 18 + ...LaunchContentIntegrityResolutionService.cs | 55 + .../Contracts/ILaunchPreparationService.cs | 35 + .../Launching/LauncherGameArgumentService.cs | 151 + .../Launching/Models/BuiltInExecutable.cs | 45 + .../Launching/Models/BuiltInExecutableKind.cs | 13 + .../Launching/Models/GameLaunchRequest.cs | 57 + .../Launching/Models/GameLaunchTargetKind.cs | 8 + ...aunchContentIntegrityResolutionProgress.cs | 40 + ...LaunchContentIntegrityResolutionRequest.cs | 30 + .../LaunchContentIntegrityTargetContext.cs | 27 + .../LaunchContentIntegrityTargetRequest.cs | 35 + ...aunchContentIntegrityVerificationResult.cs | 24 + .../LaunchContentIntegrityVersionRequest.cs | 35 + .../Models/LaunchPreparationRequest.cs | 32 + .../Mods/Contracts/ILauncherContentCatalog.cs | 90 + .../Contracts/IManualModificationImporter.cs | 19 + .../IModificationImageFileService.cs | 49 + .../Mods/Contracts/IModificationThemeCache.cs | 24 + .../LauncherContentPersistenceException.cs | 12 + .../Mods/Models/LauncherContent.cs | 160 + ...cherContentCatalogInitializationRequest.cs | 11 + .../Mods/Models/LauncherContentFileTypes.cs | 79 + .../Models/LauncherContentInstallation.cs | 38 + .../Mods/Models/LauncherContentKey.cs | 124 + .../Mods/Models/LauncherContentTheme.cs | 93 + .../Mods/Models/LauncherContentVersion.cs | 125 + .../Models/LauncherContentVersionComparer.cs | 55 + .../Mods/Models/LauncherData.cs | 235 + .../ModificationImageReplacementRequest.cs | 26 + .../Mods/Models/ModificationType.cs | 27 + .../Mods/Models/OwnedContentPath.cs | 39 + .../Services/LauncherContentPathResolver.cs | 94 + GenLauncherGO.Core/Properties/AssemblyInfo.cs | 6 + .../Remote/IRemoteConnectionProbe.cs | 16 + .../Contracts/ILauncherPreferencesService.cs | 30 + ...LauncherPreferencesPersistenceException.cs | 12 + .../Models/LauncherCustomExecutable.cs | 19 + .../Models/LauncherGamePreferences.cs | 92 + .../Models/LauncherGamePreferencesSet.cs | 28 + .../Settings/Models/LauncherInstallations.cs | 51 + .../Settings/Models/LauncherPreferences.cs | 14 + .../Models/LauncherSharedPreferences.cs | 14 + .../Shell/Contracts/ILauncherShellService.cs | 20 + .../Contracts/IGameInstallationService.cs | 96 + .../ILauncherHostEnvironmentService.cs | 40 + .../Contracts/ILauncherSingleInstanceGuard.cs | 14 + .../Startup/ILauncherPathResolver.cs | 22 + .../Startup/LauncherFileSystemLayout.cs | 138 + GenLauncherGO.Core/Startup/LauncherPaths.cs | 163 + .../Startup/LauncherRuntimePathContext.cs | 52 + .../Startup/LauncherStoragePaths.cs | 49 + .../Models/GameInstallationLocation.cs | 20 + .../GameInstallationValidationFailure.cs | 42 + .../GameInstallationValidationResult.cs | 42 + .../LauncherInstallationsValidationResult.cs | 35 + .../Startup/Models/LauncherRestartResult.cs | 27 + GenLauncherGO.Core/Startup/PerGame.cs | 54 + GenLauncherGO.Core/Startup/SupportedGame.cs | 22 + .../Contracts/IPackageDownloadService.cs | 23 + .../Contracts/IRemotePackageSizeResolver.cs | 21 + .../Models/PackageDownloadPauseController.cs | 85 + .../Updating/Models/PackageDownloadResult.cs | 63 + .../Updating/Models/PackageDownloadStatus.cs | 36 + .../Updating/Models/PackageUpdateProgress.cs | 11 + GenLauncherGO.Infrastructure/AGENTS.md | 12 + .../Archives/ArchiveExtractor.cs | 134 + .../Archives/Contracts/IArchiveExtractor.cs | 32 + .../Common/BigFileVariantPath.cs | 101 + .../Common/FileSystemPathSafety.cs | 202 + .../Common/ManifestPathResolver.cs | 132 + .../Common/OwnedDirectoryTree.cs | 430 + .../Common/PhysicalDirectoryPath.cs | 211 + .../GenLauncherGO.Infrastructure.csproj | 21 + ...frastructureServiceCollectionExtensions.cs | 80 + .../Contracts/IContentIntegrityService.cs | 52 + .../FileSystemContentIntegrityService.cs | 481 + .../Integrity/Support/ContentIntegrityPath.cs | 44 + .../Support/ContentIntegrityScanner.cs | 193 + .../Support/ContentIntegritySnapshotStore.cs | 147 + .../ILaunchContentIntegrityTargetBuilder.cs | 10 + .../Services/DeploymentFileTransaction.cs | 734 + .../DeploymentLaunchPreparationService.cs | 95 + .../Services/FileSystemDeploymentService.cs | 567 + ...LaunchContentIntegrityResolutionService.cs | 548 + ...stemLaunchContentIntegrityTargetBuilder.cs | 183 + .../WindowsGameExecutableDiscoveryService.cs | 93 + .../Services/WindowsGameProcessLauncher.cs | 141 + .../Services/WindowsProcessFamilyLauncher.cs | 683 + .../Support/DeploymentFilePlanner.cs | 100 + .../Support/DeploymentPathResolver.cs | 52 + .../Launching/Support/DeploymentResult.cs | 53 + .../Launching/Support/DeploymentStateStore.cs | 730 + .../Launching/Support/IHardLinkCreator.cs | 17 + .../Support/IProcessFamilyLaunchOperation.cs | 30 + .../Support/IProcessFamilyLauncher.cs | 19 + .../Support/WindowsHardLinkCreator.cs | 64 + .../LoggingServiceCollectionExtensions.cs | 93 + .../SensitiveDataRedactingTextFormatter.cs | 99 + .../Contracts/ILauncherContentStateStore.cs | 20 + .../Contracts/ILocalLauncherContentService.cs | 43 + .../Mods/Models/LauncherContentEntryState.cs | 28 + .../Mods/Models/LauncherContentState.cs | 15 + .../Models/LauncherContentVersionState.cs | 34 + .../LegacyCatalogAdvertisingReference.cs | 15 + .../LegacyCatalogModificationReference.cs | 17 + .../Mods/Models/LegacyContentManifest.cs | 53 + .../Mods/Models/LegacyContentThemeManifest.cs | 37 + .../Models/LegacyLauncherCatalogDocument.cs | 29 + .../Mods/Models/RemoteAdvertisingReference.cs | 26 + .../RemoteCatalogModificationReference.cs | 30 + .../Models/RemoteChildManifestLoadResult.cs | 25 + .../Mods/Models/RemoteLauncherCatalog.cs | 36 + .../Mods/Models/RemoteModificationManifest.cs | 27 + .../FileSystemLocalLauncherContentService.cs | 352 + .../FileSystemManualModificationImporter.cs | 154 + .../FileSystemModificationImageFileService.cs | 243 + .../FileSystemModificationThemeCache.cs | 133 + .../Services/LauncherCatalogImageCache.cs | 208 + .../Services/LauncherContentCatalogService.cs | 535 + .../Services/LauncherContentStateMapper.cs | 208 + .../LauncherLocalContentReconciler.cs | 178 + .../Services/RemoteLauncherCatalogClient.cs | 249 + .../Services/YamlLauncherContentStateStore.cs | 49 + .../Mods/Support/ModificationCacheFailure.cs | 16 + .../Support/ModificationImageCachePath.cs | 180 + .../Support/RemoteLauncherCatalogMapper.cs | 151 + .../Persistence/Services/AtomicFileWriter.cs | 193 + .../Persistence/Services/IAtomicFileWriter.cs | 41 + .../Services/IYamlDocumentStore.cs | 19 + .../Persistence/Services/YamlDocumentStore.cs | 83 + .../Properties/AssemblyInfo.cs | 9 + .../Contracts/IRemoteAssetDownloader.cs | 16 + .../Contracts/IRemoteYamlDocumentReader.cs | 10 + .../Remote/HttpHeadFallbackRequest.cs | 66 + .../Remote/HttpRemoteAssetDownloader.cs | 69 + .../Remote/HttpRemoteConnectionProbe.cs | 65 + .../Remote/HttpRemoteYamlDocumentReader.cs | 70 + .../Remote/SharedHttpClientFactory.cs | 34 + ...frastructureServiceCollectionExtensions.cs | 40 + .../Models/LauncherPreferencesDocument.cs | 117 + .../Settings/Services/PreferencesService.cs | 113 + .../LauncherPreferencesDocumentMapper.cs | 297 + .../Services/WindowsLauncherShellService.cs | 159 + .../Startup/FileSystemLauncherPathResolver.cs | 75 + .../Startup/IGameInstallationRegistry.cs | 12 + .../WindowsGameInstallationRegistry.cs | 126 + .../Startup/WindowsGameInstallationService.cs | 249 + .../WindowsLauncherHostEnvironmentService.cs | 186 + .../Clients/HttpDownloadFileMetadataReader.cs | 80 + .../Updating/Clients/MinioClientFactory.cs | 46 + .../Clients/MinioS3ObjectManifestReader.cs | 118 + .../Clients/ResumableHttpFileDownloader.cs | 393 + .../Contracts/IDownloadFileMetadataReader.cs | 13 + .../Updating/Contracts/IFileHashService.cs | 12 + .../Contracts/IResumableFileDownloader.cs | 14 + .../Contracts/IS3ObjectManifestReader.cs | 13 + .../Updating/Contracts/IS3PackageUpdater.cs | 24 + .../Contracts/ISingleFilePackageUpdater.cs | 17 + .../Updating/Models/DownloadFileMetadata.cs | 8 + .../Updating/Models/DownloadFileRequest.cs | 11 + .../Updating/Models/DownloadProgress.cs | 6 + .../Updating/Models/PackageUpdatePathSet.cs | 49 + .../Models/RemoteFileManifestEntry.cs | 6 + .../Models/S3ObjectManifestRequest.cs | 16 + .../Models/S3PackageFileRepairRequest.cs | 13 + .../Updating/Models/S3PackageUpdateRequest.cs | 12 + .../Services/ManagedPackageSourceResolver.cs | 201 + .../Updating/Services/Md5FileHashService.cs | 52 + .../Services/PackageDownloadService.cs | 259 + .../Updating/Services/S3PackageUpdater.cs | 582 + .../Services/SingleFilePackageUpdater.cs | 137 + .../Updating/Support/DownloadLinkResolver.cs | 76 + .../Updating/Support/InlineProgress.cs | 21 + .../Support/MonotonicPackageProgress.cs | 66 + .../Support/PackageInstallFolderReplacer.cs | 322 + .../Support/PackageProgressTracker.cs | 134 + .../Support/PackageStagingFolderCleaner.cs | 170 + .../Updating/Support/S3CatalogDefaults.cs | 74 + .../Support/S3HashValidationPolicy.cs | 81 + .../Support/S3ReusablePackageFileCopier.cs | 208 + .../GenLauncherGO.TestAnalyzers.csproj | 14 + .../TestMethodNameConvention.cs | 15 + .../TestMethodNamingAnalyzer.cs | 65 + GenLauncherGO.Tests/AGENTS.md | 63 + .../AsynchronousMethodNamingTests.cs | 123 + .../Conventions/PublicTypeConsumptionTests.cs | 129 + .../TestMethodNameConventionTests.cs | 26 + .../Core/IO/LexicalPathTests.cs | 176 + .../Models/ContentIntegrityReportTests.cs | 80 + .../Models/ContentIntegrityTargetTests.cs | 40 + .../Models/ContentSourceKindTests.cs | 20 + .../LauncherGameArgumentServiceTests.cs | 119 + ...ContentIntegrityResolutionProgressTests.cs | 29 + .../Models/LauncherContentFileTypesTests.cs | 117 + .../Mods/Models/LauncherContentKeyTests.cs | 119 + .../Core/Mods/Models/LauncherContentTests.cs | 302 + .../Mods/Models/LauncherContentThemeTests.cs | 69 + .../Models/LauncherContentVersionTests.cs | 83 + .../Core/Mods/Models/LauncherDataTests.cs | 364 + .../Core/Mods/Models/OwnedContentPathTests.cs | 35 + .../LauncherContentPathResolverTests.cs | 139 + .../Models/LauncherPreferencesTests.cs | 74 + .../GameInstallationServiceExtensionsTests.cs | 154 + .../Startup/LauncherFileSystemLayoutTests.cs | 83 + .../Core/Startup/LauncherPathsTests.cs | 151 + .../LauncherRuntimePathContextTests.cs | 55 + .../Core/Startup/LauncherStoragePathsTests.cs | 56 + .../GameInstallationValidationResultTests.cs | 17 + .../PackageDownloadPauseControllerTests.cs | 89 + .../GenLauncherGO.Tests.csproj | 59 + GenLauncherGO.Tests/GlobalUsings.cs | 4 + .../Archives/ArchiveExtractorTests.cs | 262 + .../Common/BigFileVariantPathTests.cs | 133 + .../Common/FileSystemPathSafetyTests.cs | 198 + .../Common/ManifestPathResolverTests.cs | 48 + .../Common/OwnedDirectoryTreeTests.cs | 624 + .../Common/PhysicalDirectoryPathTests.cs | 212 + .../FileSystemContentIntegrityServiceTests.cs | 1036 + .../Support/ContentIntegrityPathTests.cs | 24 + .../Support/ContentIntegrityScannerTests.cs | 169 + ...DeploymentLaunchPreparationServiceTests.cs | 201 + .../FileSystemDeploymentServiceTests.cs | 1895 ++ ...hContentIntegrityResolutionServiceTests.cs | 1065 + ...aunchContentIntegrityTargetBuilderTests.cs | 68 + ...dowsGameExecutableDiscoveryServiceTests.cs | 223 + .../WindowsGameProcessLauncherTests.cs | 272 + .../WindowsProcessFamilyLauncherTests.cs | 655 + .../Support/DeploymentFilePlannerTests.cs | 107 + .../Support/DeploymentPathResolverTests.cs | 125 + .../Support/WindowsHardLinkCreatorTests.cs | 109 + ...LoggingServiceCollectionExtensionsTests.cs | 228 + ...ensitiveDataRedactingTextFormatterTests.cs | 260 + ...eSystemLocalLauncherContentServiceTests.cs | 437 + ...leSystemManualModificationImporterTests.cs | 260 + ...SystemModificationImageFileServiceTests.cs | 549 + .../FileSystemModificationThemeCacheTests.cs | 143 + .../LauncherCatalogImageCacheTests.cs | 387 + .../LauncherContentCatalogServiceTests.cs | 1243 + .../LauncherContentStateMapperTests.cs | 702 + .../LauncherLocalContentReconcilerTests.cs | 271 + .../RemoteLauncherCatalogClientTests.cs | 418 + .../YamlLauncherContentStateStoreTests.cs | 242 + .../RemoteLauncherCatalogMapperTests.cs | 420 + .../Services/AtomicFileWriterTests.cs | 412 + .../Services/YamlDocumentStoreTests.cs | 107 + .../Remote/HttpRemoteAssetDownloaderTests.cs | 173 + .../Remote/HttpRemoteConnectionProbeTests.cs | 115 + .../HttpRemoteYamlDocumentReaderTests.cs | 93 + .../Remote/SharedHttpClientFactoryTests.cs | 136 + .../Services/PreferencesServiceTests.cs | 632 + .../WindowsLauncherShellServiceTests.cs | 176 + .../FileSystemLauncherPathResolverTests.cs | 120 + .../WindowsGameInstallationRegistryTests.cs | 122 + .../WindowsGameInstallationServiceTests.cs | 452 + ...dowsLauncherHostEnvironmentServiceTests.cs | 192 + .../HttpDownloadFileMetadataReaderTests.cs | 153 + .../Clients/MinioClientFactoryTests.cs | 24 + .../MinioS3ObjectManifestReaderTests.cs | 67 + .../ResumableHttpFileDownloaderTests.cs | 1025 + .../Updating/Models/S3RequestDefaultsTests.cs | 64 + .../ManagedPackageSourceResolverTests.cs | 264 + .../Services/Md5FileHashServiceTests.cs | 39 + .../Services/PackageDownloadServiceTests.cs | 467 + .../Services/S3PackageUpdaterBehaviorTests.cs | 861 + .../Services/SingleFilePackageUpdaterTests.cs | 401 + .../Support/DownloadLinkResolverTests.cs | 70 + .../Support/MonotonicPackageProgressTests.cs | 181 + .../PackageInstallFolderReplacerTests.cs | 478 + .../Support/PackageProgressTrackerTests.cs | 275 + .../PackageStagingFolderCleanerTests.cs | 196 + .../Support/S3HashValidationPolicyTests.cs | 51 + .../S3ReusablePackageFileCopierTests.cs | 374 + .../Properties/AssemblyInfo.cs | 6 + .../Testing/ApplicationThemeScope.cs | 67 + .../Testing/AvaloniaCollection.cs | 4 + .../CompletedGameProcessLaunchOperation.cs | 28 + .../ControllableGameProcessLaunchOperation.cs | 83 + .../ControllablePackageDownloadService.cs | 114 + GenLauncherGO.Tests/Testing/CultureScope.cs | 40 + .../Testing/DeploymentJournalWriter.cs | 55 + .../Testing/FakeGameInstallationService.cs | 60 + .../Testing/FakeHardLinkCreator.cs | 64 + .../Testing/FakeLaunchPreparationService.cs | 69 + .../Testing/FakeLauncherContentCatalog.cs | 157 + .../Testing/FakeModificationThemeCache.cs | 25 + .../Testing/FakeStringLocalizer.cs | 104 + .../Testing/LocalizationResourceKeys.cs | 51 + .../Testing/ManualTimeProvider.cs | 47 + .../Testing/QueueHttpMessageHandler.cs | 41 + .../Testing/RecordingArchiveExtractor.cs | 34 + .../Testing/RecordingAtomicFileWriter.cs | 46 + .../Testing/RecordingFileDownloader.cs | 52 + .../RecordingLauncherContentStateStore.cs | 37 + .../Testing/RecordingLauncherDialogService.cs | 86 + .../RecordingLauncherPreferencesService.cs | 55 + .../RecordingLocalLauncherContentService.cs | 71 + .../Testing/RecordingLogger.cs | 45 + .../Testing/RecordingProgress.cs | 21 + .../Testing/RecordingRemoteAssetDownloader.cs | 26 + .../RecordingS3ObjectManifestReader.cs | 44 + .../Testing/RecordingS3PackageUpdater.cs | 57 + .../RecordingSingleFilePackageUpdater.cs | 47 + .../Testing/RecordingStartupDialogService.cs | 48 + .../Testing/ReparsePointTestSupport.cs | 172 + GenLauncherGO.Tests/Testing/StaTestRunner.cs | 61 + .../Testing/StaTestRunnerTests.cs | 71 + .../Testing/StubDownloadFileMetadataReader.cs | 42 + .../Testing/StubFileHashService.cs | 26 + .../Testing/StubLauncherDialogService.cs | 27 + .../Testing/StubLauncherFilePicker.cs | 53 + .../Testing/StubRemoteYamlDocumentReader.cs | 98 + .../Testing/SymbolicLinkFactAttribute.cs | 18 + .../Testing/SymbolicLinkTestSupport.cs | 102 + GenLauncherGO.Tests/Testing/TestDirectory.cs | 114 + GenLauncherGO.Tests/Testing/TestImageFile.cs | 28 + .../TestLaunchContentIntegrityCoordinator.cs | 51 + .../Testing/TestLauncherContent.cs | 188 + .../Testing/TestLauncherInstallations.cs | 36 + .../Testing/TestLauncherLaunchCoordinator.cs | 87 + .../Testing/TestLauncherPaths.cs | 96 + .../Testing/TestLauncherRuntimeContext.cs | 38 + .../Testing/TestLauncherTheme.cs | 38 + .../Testing/TestLocalizedStrings.cs | 185 + .../Testing/TestMainWindowViewModel.cs | 59 + .../Testing/TestModificationTile.cs | 32 + .../Testing/TestPackageDownload.cs | 57 + .../Testing/TestPackageUpdatePaths.cs | 36 + GenLauncherGO.Tests/Testing/TestTimeouts.cs | 15 + .../AvaloniaLauncherDialogServiceTests.cs | 308 + .../LaunchContentIntegrityCoordinatorTests.cs | 635 + .../LauncherPackageActivityServiceTests.cs | 277 + .../IntegrityReviewViewModelTests.cs | 187 + .../Services/LauncherCloseGuardTests.cs | 195 + ...LauncherExecutableSelectionServiceTests.cs | 188 + .../LauncherGameSessionCoordinatorTests.cs | 681 + .../LauncherLaunchCoordinatorTests.cs | 763 + ...LauncherLaunchReadinessCoordinatorTests.cs | 537 + .../LauncherManualImportCoordinatorTests.cs | 369 + ...herModificationDownloadCoordinatorTests.cs | 425 + .../LauncherRestartCoordinatorTests.cs | 91 + .../LauncherTileActionServiceTests.cs | 284 + ...erWindowWorkflowCoordinatorClosingTests.cs | 369 + ...rWindowWorkflowCoordinatorDownloadTests.cs | 338 + ...auncherWindowWorkflowCoordinatorFixture.cs | 731 + ...WindowWorkflowCoordinatorLaunchingTests.cs | 210 + .../LauncherWindowWorkflowCoordinatorTests.cs | 766 + ...WorkflowCoordinatorVersionDeletionTests.cs | 181 + .../LauncherDragDropControllerTests.cs | 638 + .../LauncherWindowListControllerTests.cs | 682 + .../ViewModels/MainWindowViewModelTests.cs | 1352 + .../ModificationImageSourceFactoryTests.cs | 89 + .../Mods/ModificationViewModelTests.cs | 471 + .../AddModificationViewModelTests.cs | 293 + .../ManualAddModificationViewModelTests.cs | 275 + .../ModificationTileImageProviderTests.cs | 244 + ...ncherExecutableManagementViewModelTests.cs | 526 + .../LauncherSettingsViewModelTests.cs | 305 + .../Settings/LauncherSettingsWindowTests.cs | 354 + .../LauncherApplicationCompositionTests.cs | 39 + .../Startup/LauncherApplicationHostTests.cs | 471 + .../Startup/LauncherElevationManifestTests.cs | 49 + .../Startup/LauncherStartupCultureTests.cs | 38 + .../AvaloniaStandaloneStartupWorkflowTests.cs | 150 + .../LauncherShutdownCoordinatorTests.cs | 138 + .../ViewModels/InitWindowViewModelTests.cs | 265 + .../LauncherGameInstallationViewModelTests.cs | 152 + .../LauncherGameSelectionViewModelTests.cs | 94 + .../LauncherInstallationsViewModelTests.cs | 534 + .../ViewModels/LauncherSetupViewModelTests.cs | 111 + .../UI/LauncherThemeRenderedStateTests.cs | 96 + .../UI/NativeAxamlSmokeTests.cs | 476 + .../Controls/LauncherTextBoxFeedbackTests.cs | 70 + .../Controls/LauncherWindowScalingTests.cs | 144 + .../AvaloniaUiExceptionBoundaryTests.cs | 130 + .../Formatting/ByteSizeFormatterTests.cs | 33 + .../PackageProgressTextFormatterTests.cs | 117 + .../AvaloniaLauncherStringLocalizerTests.cs | 46 + .../Shared/Localization/LocExtensionTests.cs | 35 + .../LocalizationResourceParityTests.cs | 232 + .../Themes/LauncherThemePresetsTests.cs | 33 + .../Themes/LauncherThemeResolverTests.cs | 172 + .../LauncherThemeResourceApplierTests.cs | 195 + GenLauncherGO.Tests/stryker-core.json | 53 + .../stryker-infrastructure-common.json | 42 + .../stryker-infrastructure-integrity.json | 36 + .../stryker-infrastructure-launching.json | 43 + .../stryker-infrastructure-mods.json | 46 + .../stryker-infrastructure-platform.json | 43 + .../stryker-infrastructure-updating.json | 51 + GenLauncherGO.UI/AGENTS.md | 15 + GenLauncherGO.UI/App.axaml | 393 + .../Contracts/ILauncherDialogService.cs | 69 + .../Models/LauncherInfoDialogRequest.cs | 31 + .../Models/ManualModificationDialogResult.cs | 13 + .../Services/AvaloniaLauncherDialogService.cs | 199 + .../ILaunchContentIntegrityProgressTarget.cs | 32 + .../Integrity/IntegrityReviewDialog.axaml | 147 + .../Integrity/IntegrityReviewDialog.axaml.cs | 48 + .../LaunchContentIntegrityCoordinator.cs | 456 + .../LauncherPackageActivityService.cs | 566 + .../ViewModels/IntegrityReviewViewModel.cs | 221 + .../Launcher/Contracts/ILauncherFilePicker.cs | 42 + .../Launcher/Models/ExecutableOption.cs | 37 + .../Launcher/Models/LauncherCloseReason.cs | 8 + .../Models/LauncherContentViewKind.cs | 12 + .../Models/LauncherLaunchFailureKind.cs | 16 + .../Launcher/Models/LauncherLaunchRequest.cs | 22 + .../Launcher/Models/LauncherLaunchResult.cs | 17 + .../Launcher/Models/LauncherTileLinkAction.cs | 5 + .../Launcher/Models/LauncherTileLinkKind.cs | 32 + .../Launcher/Models/LauncherWindowContext.cs | 35 + .../Services/AvaloniaLauncherFilePicker.cs | 189 + .../Launcher/Services/LauncherCloseGuard.cs | 141 + .../LauncherContentActionCoordinator.cs | 427 + .../LauncherExecutableSelectionService.cs | 147 + .../LauncherGameSessionCoordinator.cs | 351 + .../Services/LauncherLaunchCoordinator.cs | 479 + .../LauncherLaunchReadinessCoordinator.cs | 226 + .../LauncherManualImportCoordinator.cs | 204 + ...LauncherModificationDownloadCoordinator.cs | 424 + ...LauncherPackageActivityAdmissionService.cs | 83 + .../Services/LauncherTileActionService.cs | 112 + .../LauncherWindowWorkflowCoordinator.cs | 474 + .../Support/LauncherDragDropController.cs | 358 + .../Support/LauncherWindowListController.cs | 268 + .../ViewModels/MainWindowViewModel.cs | 1259 + .../Launcher/Views/LauncherContentListBox.cs | 33 + .../Features/Launcher/Views/MainWindow.axaml | 1203 + .../Launcher/Views/MainWindow.axaml.cs | 613 + .../Mods/ModificationImageSourceFactory.cs | 208 + .../Mods/ModificationVersionSelection.cs | 32 + .../Features/Mods/ModificationViewModel.cs | 803 + .../Resources/UserAddedModBannerGenerals.jpg | Bin .../Resources/UserAddedModBannerZeroHour.jpg | Bin .../AddModificationItemViewModel.cs | 50 + .../ViewModels/AddModificationViewModel.cs | 260 + .../Mods/ViewModels/InfoDialogKind.cs | 12 + .../Mods/ViewModels/InfoDialogViewModel.cs | 122 + .../ManualAddModificationViewModel.cs | 240 + .../ModificationTileImageProvider.cs | 249 + .../Mods/Views/AddModificationWindow.axaml | 100 + .../Mods/Views/AddModificationWindow.axaml.cs | 59 + .../Features/Mods/Views/InfoWindow.axaml | 172 + .../Features/Mods/Views/InfoWindow.axaml.cs | 53 + .../Views/ManualAddModificationWindow.axaml | 65 + .../ManualAddModificationWindow.axaml.cs | 49 + .../Settings/LauncherPreferencesUpdate.cs | 30 + .../LauncherExecutableManagementViewModel.cs | 428 + .../ViewModels/LauncherSettingsViewModel.cs | 242 + .../LauncherExecutableEditorWindow.axaml | 77 + .../LauncherExecutableEditorWindow.axaml.cs | 75 + .../LauncherExecutableManagementWindow.axaml | 185 + ...auncherExecutableManagementWindow.axaml.cs | 116 + .../Views/LauncherSettingsWindow.axaml | 282 + .../Views/LauncherSettingsWindow.axaml.cs | 232 + .../Contracts/IStandaloneStartupWorkflow.cs | 26 + .../Contracts/IStartupDialogService.cs | 23 + .../Features/Startup/EntryPoint.cs | 28 + .../Startup/LauncherApplicationDefaults.cs | 17 + .../Startup/LauncherApplicationHost.cs | 438 + .../Startup/LauncherAvaloniaApplication.cs | 61 + .../Startup/LauncherRuntimeContext.cs | 82 + .../LauncherUiServiceCollectionExtensions.cs | 76 + .../Startup/Models/StandaloneStartupResult.cs | 34 + .../AvaloniaStandaloneStartupWorkflow.cs | 177 + .../Services/AvaloniaStartupDialogService.cs | 71 + .../Services/LauncherRestartCoordinator.cs | 61 + .../Services/LauncherShutdownCoordinator.cs | 86 + .../Services/LauncherStartupCulture.cs | 25 + .../InitWindowStartupCompletedEventArgs.cs | 13 + .../Startup/ViewModels/InitWindowViewModel.cs | 181 + .../ViewModels/LauncherGameChoiceViewModel.cs | 62 + .../LauncherGameInstallationViewModel.cs | 68 + .../LauncherGameSelectionViewModel.cs | 97 + .../LauncherInstallationsViewModel.cs | 364 + .../ViewModels/LauncherSetupViewModel.cs | 71 + .../Features/Startup/Views/InitWindow.axaml | 15 + .../Startup/Views/InitWindow.axaml.cs | 134 + .../Views/LauncherGameSelectionWindow.axaml | 141 + .../LauncherGameSelectionWindow.axaml.cs | 103 + .../Views/LauncherLocationWarningWindow.axaml | 100 + .../LauncherLocationWarningWindow.axaml.cs | 47 + .../Startup/Views/LauncherSetupWindow.axaml | 198 + .../Views/LauncherSetupWindow.axaml.cs | 184 + GenLauncherGO.UI/GenLauncherGO.UI.csproj | 60 + GenLauncherGO.UI/Properties/AssemblyInfo.cs | 9 + .../WinX64SelfContained.pubxml | 15 + GenLauncherGO.UI/Resources/Strings.ar.resx | 746 + GenLauncherGO.UI/Resources/Strings.cs | 19 + GenLauncherGO.UI/Resources/Strings.de.resx | 748 + GenLauncherGO.UI/Resources/Strings.es.resx | 746 + GenLauncherGO.UI/Resources/Strings.fa.resx | 369 + GenLauncherGO.UI/Resources/Strings.fr.resx | 746 + GenLauncherGO.UI/Resources/Strings.hr.resx | 746 + GenLauncherGO.UI/Resources/Strings.pt.resx | 746 + GenLauncherGO.UI/Resources/Strings.resx | 803 + GenLauncherGO.UI/Resources/Strings.ru.resx | 746 + GenLauncherGO.UI/Resources/Strings.tr.resx | 746 + GenLauncherGO.UI/Resources/Strings.uk.resx | 746 + GenLauncherGO.UI/Resources/Strings.zh.resx | 746 + .../Controls/LauncherLoadingIndicator.axaml | 35 + .../LauncherLoadingIndicator.axaml.cs | 17 + .../Controls/LauncherTextBoxFeedback.cs | 101 + .../Controls/LauncherWindowDragHandle.cs | 59 + .../Shared/Controls/LauncherWindowScaling.cs | 187 + .../Shared/Controls/UpdateButton.cs | 48 + .../Shared/Dialogs/AvaloniaDialog.cs | 105 + .../Errors/AvaloniaUiExceptionBoundary.cs | 109 + .../Shared/Errors/IUiExceptionBoundary.cs | 35 + .../Shared/Errors/UiOperationOutcome.cs | 11 + .../Shared/Formatting/ByteSizeFormatter.cs | 53 + .../PackageProgressTextFormatter.cs | 83 + .../AvaloniaLauncherStringLocalizer.cs | 21 + .../Localization/ILauncherStringLocalizer.cs | 9 + .../Shared/Localization/LocExtension.cs | 30 + .../Shared/Resources/Icons/GenLauncherGo.ico | Bin .../Images/LauncherBackgroundGenerals.png | Bin .../Images/LauncherBackgroundZeroHour.png | Bin .../Images/LauncherEmblemCompact.png | Bin .../Resources/Images/LauncherEmblemFramed.png | Bin .../Images/OfficialCoverGenerals.png | Bin 0 -> 2219212 bytes .../Images/OfficialCoverZeroHour.jpg | Bin 0 -> 32094 bytes GenLauncherGO.UI/Shared/Themes/ColorsInfo.cs | 137 + .../Shared/Themes/GenLauncherStyles.axaml | 211 + .../Shared/Themes/LauncherThemePresets.cs | 94 + .../Shared/Themes/LauncherThemeResolver.cs | 85 + .../Themes/LauncherThemeResourceApplier.cs | 133 + GenLauncherGO.UI/app.manifest | 10 + GenLauncherGO.sln | 54 + 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/ColorsDictionary.xaml | 20 - 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 - NuGet.config | 22 + README.md | 134 +- WpfSurface.dxvk-cache | Bin 1464 -> 0 bytes coverage.runsettings | 24 + docs/images/genlaunchergo-zero-hour.png | Bin 0 -> 1028366 bytes eng/coverage.proj | 97 + eng/mutation.proj | 41 + global.json | 7 + 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 - 749 files changed, 91062 insertions(+), 167436 deletions(-) create mode 100644 .codex/config.toml create mode 100644 .config/dotnet-tools.json create mode 100644 .editorconfig create mode 100644 .gitattributes delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/dependency-audit.yml create mode 100644 .mcp.json create mode 100644 AGENTS.md create mode 100644 CONTRIBUTING.md create mode 100644 Claude.md create mode 100644 Directory.Build.props create mode 100644 Directory.Build.targets create mode 100644 Directory.Packages.props delete mode 100644 GenLauncher.sln create mode 100644 GenLauncherGO.Core/AGENTS.md create mode 100644 GenLauncherGO.Core/GenLauncherGO.Core.csproj create mode 100644 GenLauncherGO.Core/IO/LexicalPath.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/IGameExecutableDiscoveryService.cs create mode 100644 GenLauncherGO.Core/Launching/Contracts/IGameProcessLaunchOperation.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/ILaunchPreparationService.cs create mode 100644 GenLauncherGO.Core/Launching/LauncherGameArgumentService.cs create mode 100644 GenLauncherGO.Core/Launching/Models/BuiltInExecutable.cs create mode 100644 GenLauncherGO.Core/Launching/Models/BuiltInExecutableKind.cs create mode 100644 GenLauncherGO.Core/Launching/Models/GameLaunchRequest.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/Mods/Contracts/ILauncherContentCatalog.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/Contracts/IModificationThemeCache.cs create mode 100644 GenLauncherGO.Core/Mods/Exceptions/LauncherContentPersistenceException.cs create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContent.cs create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentFileTypes.cs create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentInstallation.cs create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentKey.cs create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentTheme.cs create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentVersion.cs create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentVersionComparer.cs create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherData.cs create mode 100644 GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs create mode 100644 GenLauncherGO.Core/Mods/Models/ModificationType.cs create mode 100644 GenLauncherGO.Core/Mods/Models/OwnedContentPath.cs create mode 100644 GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs create mode 100644 GenLauncherGO.Core/Properties/AssemblyInfo.cs create mode 100644 GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs create mode 100644 GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs create mode 100644 GenLauncherGO.Core/Settings/Exceptions/LauncherPreferencesPersistenceException.cs create mode 100644 GenLauncherGO.Core/Settings/Models/LauncherCustomExecutable.cs create mode 100644 GenLauncherGO.Core/Settings/Models/LauncherGamePreferences.cs create mode 100644 GenLauncherGO.Core/Settings/Models/LauncherGamePreferencesSet.cs create mode 100644 GenLauncherGO.Core/Settings/Models/LauncherInstallations.cs create mode 100644 GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs create mode 100644 GenLauncherGO.Core/Settings/Models/LauncherSharedPreferences.cs create mode 100644 GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs create mode 100644 GenLauncherGO.Core/Startup/Contracts/IGameInstallationService.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/ILauncherPathResolver.cs create mode 100644 GenLauncherGO.Core/Startup/LauncherFileSystemLayout.cs create mode 100644 GenLauncherGO.Core/Startup/LauncherPaths.cs create mode 100644 GenLauncherGO.Core/Startup/LauncherRuntimePathContext.cs create mode 100644 GenLauncherGO.Core/Startup/LauncherStoragePaths.cs create mode 100644 GenLauncherGO.Core/Startup/Models/GameInstallationLocation.cs create mode 100644 GenLauncherGO.Core/Startup/Models/GameInstallationValidationFailure.cs create mode 100644 GenLauncherGO.Core/Startup/Models/GameInstallationValidationResult.cs create mode 100644 GenLauncherGO.Core/Startup/Models/LauncherInstallationsValidationResult.cs create mode 100644 GenLauncherGO.Core/Startup/Models/LauncherRestartResult.cs create mode 100644 GenLauncherGO.Core/Startup/PerGame.cs create mode 100644 GenLauncherGO.Core/Startup/SupportedGame.cs create mode 100644 GenLauncherGO.Core/Updating/Contracts/IPackageDownloadService.cs create mode 100644 GenLauncherGO.Core/Updating/Contracts/IRemotePackageSizeResolver.cs create mode 100644 GenLauncherGO.Core/Updating/Models/PackageDownloadPauseController.cs create mode 100644 GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs create mode 100644 GenLauncherGO.Core/Updating/Models/PackageDownloadStatus.cs create mode 100644 GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs create mode 100644 GenLauncherGO.Infrastructure/AGENTS.md create mode 100644 GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs create mode 100644 GenLauncherGO.Infrastructure/Archives/Contracts/IArchiveExtractor.cs create mode 100644 GenLauncherGO.Infrastructure/Common/BigFileVariantPath.cs create mode 100644 GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs create mode 100644 GenLauncherGO.Infrastructure/Common/ManifestPathResolver.cs create mode 100644 GenLauncherGO.Infrastructure/Common/OwnedDirectoryTree.cs create mode 100644 GenLauncherGO.Infrastructure/Common/PhysicalDirectoryPath.cs create mode 100644 GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj create mode 100644 GenLauncherGO.Infrastructure/InfrastructureServiceCollectionExtensions.cs create mode 100644 GenLauncherGO.Infrastructure/Integrity/Contracts/IContentIntegrityService.cs create mode 100644 GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs create mode 100644 GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityPath.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/Contracts/ILaunchContentIntegrityTargetBuilder.cs create mode 100644 GenLauncherGO.Infrastructure/Launching/Services/DeploymentFileTransaction.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/DeploymentResult.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/IProcessFamilyLaunchOperation.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/Logging/SensitiveDataRedactingTextFormatter.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherContentStateStore.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Contracts/ILocalLauncherContentService.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/LauncherContentEntryState.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/LauncherContentState.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/LauncherContentVersionState.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogAdvertisingReference.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogModificationReference.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/LegacyContentManifest.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/LegacyContentThemeManifest.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/LegacyLauncherCatalogDocument.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/RemoteChildManifestLoadResult.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/FileSystemModificationThemeCache.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/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/ModificationCacheFailure.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Support/ModificationImageCachePath.cs create mode 100644 GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs create mode 100644 GenLauncherGO.Infrastructure/Persistence/Services/AtomicFileWriter.cs create mode 100644 GenLauncherGO.Infrastructure/Persistence/Services/IAtomicFileWriter.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/Contracts/IRemoteAssetDownloader.cs create mode 100644 GenLauncherGO.Infrastructure/Remote/Contracts/IRemoteYamlDocumentReader.cs create mode 100644 GenLauncherGO.Infrastructure/Remote/HttpHeadFallbackRequest.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/Models/LauncherPreferencesDocument.cs create mode 100644 GenLauncherGO.Infrastructure/Settings/Services/PreferencesService.cs create mode 100644 GenLauncherGO.Infrastructure/Settings/Support/LauncherPreferencesDocumentMapper.cs create mode 100644 GenLauncherGO.Infrastructure/Shell/Services/WindowsLauncherShellService.cs create mode 100644 GenLauncherGO.Infrastructure/Startup/FileSystemLauncherPathResolver.cs create mode 100644 GenLauncherGO.Infrastructure/Startup/IGameInstallationRegistry.cs create mode 100644 GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationRegistry.cs create mode 100644 GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationService.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/Contracts/IDownloadFileMetadataReader.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Contracts/IFileHashService.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Contracts/IResumableFileDownloader.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/Contracts/ISingleFilePackageUpdater.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/DownloadFileMetadata.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/DownloadFileRequest.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/DownloadProgress.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/PackageUpdatePathSet.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/RemoteFileManifestEntry.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/S3ObjectManifestRequest.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/S3PackageFileRepairRequest.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/S3PackageUpdateRequest.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Services/ManagedPackageSourceResolver.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Services/Md5FileHashService.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Services/PackageDownloadService.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/Support/DownloadLinkResolver.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/InlineProgress.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/MonotonicPackageProgress.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.TestAnalyzers/GenLauncherGO.TestAnalyzers.csproj create mode 100644 GenLauncherGO.TestAnalyzers/TestMethodNameConvention.cs create mode 100644 GenLauncherGO.TestAnalyzers/TestMethodNamingAnalyzer.cs create mode 100644 GenLauncherGO.Tests/AGENTS.md create mode 100644 GenLauncherGO.Tests/Conventions/AsynchronousMethodNamingTests.cs create mode 100644 GenLauncherGO.Tests/Conventions/PublicTypeConsumptionTests.cs create mode 100644 GenLauncherGO.Tests/Conventions/TestMethodNameConventionTests.cs create mode 100644 GenLauncherGO.Tests/Core/IO/LexicalPathTests.cs 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/Integrity/Models/ContentSourceKindTests.cs create mode 100644 GenLauncherGO.Tests/Core/Launching/LauncherGameArgumentServiceTests.cs create mode 100644 GenLauncherGO.Tests/Core/Launching/Models/LaunchContentIntegrityResolutionProgressTests.cs create mode 100644 GenLauncherGO.Tests/Core/Mods/Models/LauncherContentFileTypesTests.cs create mode 100644 GenLauncherGO.Tests/Core/Mods/Models/LauncherContentKeyTests.cs create mode 100644 GenLauncherGO.Tests/Core/Mods/Models/LauncherContentTests.cs create mode 100644 GenLauncherGO.Tests/Core/Mods/Models/LauncherContentThemeTests.cs create mode 100644 GenLauncherGO.Tests/Core/Mods/Models/LauncherContentVersionTests.cs create mode 100644 GenLauncherGO.Tests/Core/Mods/Models/LauncherDataTests.cs create mode 100644 GenLauncherGO.Tests/Core/Mods/Models/OwnedContentPathTests.cs create mode 100644 GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs create mode 100644 GenLauncherGO.Tests/Core/Settings/Models/LauncherPreferencesTests.cs create mode 100644 GenLauncherGO.Tests/Core/Startup/GameInstallationServiceExtensionsTests.cs create mode 100644 GenLauncherGO.Tests/Core/Startup/LauncherFileSystemLayoutTests.cs create mode 100644 GenLauncherGO.Tests/Core/Startup/LauncherPathsTests.cs create mode 100644 GenLauncherGO.Tests/Core/Startup/LauncherRuntimePathContextTests.cs create mode 100644 GenLauncherGO.Tests/Core/Startup/LauncherStoragePathsTests.cs create mode 100644 GenLauncherGO.Tests/Core/Startup/Models/GameInstallationValidationResultTests.cs create mode 100644 GenLauncherGO.Tests/Core/Updating/Models/PackageDownloadPauseControllerTests.cs create mode 100644 GenLauncherGO.Tests/GenLauncherGO.Tests.csproj create mode 100644 GenLauncherGO.Tests/GlobalUsings.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Archives/ArchiveExtractorTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Common/BigFileVariantPathTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Common/FileSystemPathSafetyTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Common/ManifestPathResolverTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Common/OwnedDirectoryTreeTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Common/PhysicalDirectoryPathTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Integrity/Support/ContentIntegrityPathTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Integrity/Support/ContentIntegrityScannerTests.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/FileSystemLaunchContentIntegrityResolutionServiceTests.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/Launching/Services/WindowsProcessFamilyLauncherTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentFilePlannerTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentPathResolverTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Support/WindowsHardLinkCreatorTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Logging/LoggingServiceCollectionExtensionsTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Logging/SensitiveDataRedactingTextFormatterTests.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/FileSystemModificationThemeCacheTests.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/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/Mods/Support/RemoteLauncherCatalogMapperTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Persistence/Services/AtomicFileWriterTests.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/Remote/HttpRemoteConnectionProbeTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteYamlDocumentReaderTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Remote/SharedHttpClientFactoryTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.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/WindowsGameInstallationRegistryTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Clients/HttpDownloadFileMetadataReaderTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioClientFactoryTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioS3ObjectManifestReaderTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Clients/ResumableHttpFileDownloaderTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Services/ManagedPackageSourceResolverTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Services/Md5FileHashServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.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/Infrastructure/Updating/Support/MonotonicPackageProgressTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageInstallFolderReplacerTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageProgressTrackerTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageStagingFolderCleanerTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Support/S3HashValidationPolicyTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Support/S3ReusablePackageFileCopierTests.cs create mode 100644 GenLauncherGO.Tests/Properties/AssemblyInfo.cs create mode 100644 GenLauncherGO.Tests/Testing/ApplicationThemeScope.cs create mode 100644 GenLauncherGO.Tests/Testing/AvaloniaCollection.cs create mode 100644 GenLauncherGO.Tests/Testing/CompletedGameProcessLaunchOperation.cs create mode 100644 GenLauncherGO.Tests/Testing/ControllableGameProcessLaunchOperation.cs create mode 100644 GenLauncherGO.Tests/Testing/ControllablePackageDownloadService.cs create mode 100644 GenLauncherGO.Tests/Testing/CultureScope.cs create mode 100644 GenLauncherGO.Tests/Testing/DeploymentJournalWriter.cs create mode 100644 GenLauncherGO.Tests/Testing/FakeGameInstallationService.cs create mode 100644 GenLauncherGO.Tests/Testing/FakeHardLinkCreator.cs create mode 100644 GenLauncherGO.Tests/Testing/FakeLaunchPreparationService.cs create mode 100644 GenLauncherGO.Tests/Testing/FakeLauncherContentCatalog.cs create mode 100644 GenLauncherGO.Tests/Testing/FakeModificationThemeCache.cs create mode 100644 GenLauncherGO.Tests/Testing/FakeStringLocalizer.cs create mode 100644 GenLauncherGO.Tests/Testing/LocalizationResourceKeys.cs create mode 100644 GenLauncherGO.Tests/Testing/ManualTimeProvider.cs create mode 100644 GenLauncherGO.Tests/Testing/QueueHttpMessageHandler.cs create mode 100644 GenLauncherGO.Tests/Testing/RecordingArchiveExtractor.cs create mode 100644 GenLauncherGO.Tests/Testing/RecordingAtomicFileWriter.cs create mode 100644 GenLauncherGO.Tests/Testing/RecordingFileDownloader.cs create mode 100644 GenLauncherGO.Tests/Testing/RecordingLauncherContentStateStore.cs create mode 100644 GenLauncherGO.Tests/Testing/RecordingLauncherDialogService.cs create mode 100644 GenLauncherGO.Tests/Testing/RecordingLauncherPreferencesService.cs create mode 100644 GenLauncherGO.Tests/Testing/RecordingLocalLauncherContentService.cs create mode 100644 GenLauncherGO.Tests/Testing/RecordingLogger.cs create mode 100644 GenLauncherGO.Tests/Testing/RecordingProgress.cs create mode 100644 GenLauncherGO.Tests/Testing/RecordingRemoteAssetDownloader.cs create mode 100644 GenLauncherGO.Tests/Testing/RecordingS3ObjectManifestReader.cs create mode 100644 GenLauncherGO.Tests/Testing/RecordingS3PackageUpdater.cs create mode 100644 GenLauncherGO.Tests/Testing/RecordingSingleFilePackageUpdater.cs create mode 100644 GenLauncherGO.Tests/Testing/RecordingStartupDialogService.cs create mode 100644 GenLauncherGO.Tests/Testing/ReparsePointTestSupport.cs create mode 100644 GenLauncherGO.Tests/Testing/StaTestRunner.cs create mode 100644 GenLauncherGO.Tests/Testing/StaTestRunnerTests.cs create mode 100644 GenLauncherGO.Tests/Testing/StubDownloadFileMetadataReader.cs create mode 100644 GenLauncherGO.Tests/Testing/StubFileHashService.cs create mode 100644 GenLauncherGO.Tests/Testing/StubLauncherDialogService.cs create mode 100644 GenLauncherGO.Tests/Testing/StubLauncherFilePicker.cs create mode 100644 GenLauncherGO.Tests/Testing/StubRemoteYamlDocumentReader.cs create mode 100644 GenLauncherGO.Tests/Testing/SymbolicLinkFactAttribute.cs create mode 100644 GenLauncherGO.Tests/Testing/SymbolicLinkTestSupport.cs create mode 100644 GenLauncherGO.Tests/Testing/TestDirectory.cs create mode 100644 GenLauncherGO.Tests/Testing/TestImageFile.cs create mode 100644 GenLauncherGO.Tests/Testing/TestLaunchContentIntegrityCoordinator.cs create mode 100644 GenLauncherGO.Tests/Testing/TestLauncherContent.cs create mode 100644 GenLauncherGO.Tests/Testing/TestLauncherInstallations.cs create mode 100644 GenLauncherGO.Tests/Testing/TestLauncherLaunchCoordinator.cs create mode 100644 GenLauncherGO.Tests/Testing/TestLauncherPaths.cs create mode 100644 GenLauncherGO.Tests/Testing/TestLauncherRuntimeContext.cs create mode 100644 GenLauncherGO.Tests/Testing/TestLauncherTheme.cs create mode 100644 GenLauncherGO.Tests/Testing/TestLocalizedStrings.cs create mode 100644 GenLauncherGO.Tests/Testing/TestMainWindowViewModel.cs create mode 100644 GenLauncherGO.Tests/Testing/TestModificationTile.cs create mode 100644 GenLauncherGO.Tests/Testing/TestPackageDownload.cs create mode 100644 GenLauncherGO.Tests/Testing/TestPackageUpdatePaths.cs create mode 100644 GenLauncherGO.Tests/Testing/TestTimeouts.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Dialogs/Services/AvaloniaLauncherDialogServiceTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Integrity/LaunchContentIntegrityCoordinatorTests.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/Services/LauncherCloseGuardTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherExecutableSelectionServiceTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherGameSessionCoordinatorTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherLaunchCoordinatorTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherLaunchReadinessCoordinatorTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherManualImportCoordinatorTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherModificationDownloadCoordinatorTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherRestartCoordinatorTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTileActionServiceTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherWindowWorkflowCoordinatorClosingTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherWindowWorkflowCoordinatorDownloadTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherWindowWorkflowCoordinatorFixture.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherWindowWorkflowCoordinatorLaunchingTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherWindowWorkflowCoordinatorTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherWindowWorkflowCoordinatorVersionDeletionTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Support/LauncherDragDropControllerTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Support/LauncherWindowListControllerTests.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/ViewModels/AddModificationViewModelTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ManualAddModificationViewModelTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModificationTileImageProviderTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Settings/LauncherExecutableManagementViewModelTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsViewModelTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsWindowTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/LauncherApplicationCompositionTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/LauncherApplicationHostTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/LauncherElevationManifestTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/LauncherStartupCultureTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/Services/AvaloniaStandaloneStartupWorkflowTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/Services/LauncherShutdownCoordinatorTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/ViewModels/InitWindowViewModelTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/ViewModels/LauncherGameInstallationViewModelTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/ViewModels/LauncherGameSelectionViewModelTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/ViewModels/LauncherInstallationsViewModelTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/ViewModels/LauncherSetupViewModelTests.cs create mode 100644 GenLauncherGO.Tests/UI/LauncherThemeRenderedStateTests.cs create mode 100644 GenLauncherGO.Tests/UI/NativeAxamlSmokeTests.cs create mode 100644 GenLauncherGO.Tests/UI/Shared/Controls/LauncherTextBoxFeedbackTests.cs create mode 100644 GenLauncherGO.Tests/UI/Shared/Controls/LauncherWindowScalingTests.cs create mode 100644 GenLauncherGO.Tests/UI/Shared/Errors/AvaloniaUiExceptionBoundaryTests.cs create mode 100644 GenLauncherGO.Tests/UI/Shared/Formatting/ByteSizeFormatterTests.cs create mode 100644 GenLauncherGO.Tests/UI/Shared/Formatting/PackageProgressTextFormatterTests.cs create mode 100644 GenLauncherGO.Tests/UI/Shared/Localization/AvaloniaLauncherStringLocalizerTests.cs create mode 100644 GenLauncherGO.Tests/UI/Shared/Localization/LocExtensionTests.cs create mode 100644 GenLauncherGO.Tests/UI/Shared/Localization/LocalizationResourceParityTests.cs create mode 100644 GenLauncherGO.Tests/UI/Shared/Themes/LauncherThemePresetsTests.cs create mode 100644 GenLauncherGO.Tests/UI/Shared/Themes/LauncherThemeResolverTests.cs create mode 100644 GenLauncherGO.Tests/UI/Shared/Themes/LauncherThemeResourceApplierTests.cs create mode 100644 GenLauncherGO.Tests/stryker-core.json create mode 100644 GenLauncherGO.Tests/stryker-infrastructure-common.json create mode 100644 GenLauncherGO.Tests/stryker-infrastructure-integrity.json create mode 100644 GenLauncherGO.Tests/stryker-infrastructure-launching.json create mode 100644 GenLauncherGO.Tests/stryker-infrastructure-mods.json create mode 100644 GenLauncherGO.Tests/stryker-infrastructure-platform.json create mode 100644 GenLauncherGO.Tests/stryker-infrastructure-updating.json create mode 100644 GenLauncherGO.UI/AGENTS.md create mode 100644 GenLauncherGO.UI/App.axaml create mode 100644 GenLauncherGO.UI/Features/Dialogs/Contracts/ILauncherDialogService.cs create mode 100644 GenLauncherGO.UI/Features/Dialogs/Models/LauncherInfoDialogRequest.cs create mode 100644 GenLauncherGO.UI/Features/Dialogs/Models/ManualModificationDialogResult.cs create mode 100644 GenLauncherGO.UI/Features/Dialogs/Services/AvaloniaLauncherDialogService.cs create mode 100644 GenLauncherGO.UI/Features/Integrity/ILaunchContentIntegrityProgressTarget.cs create mode 100644 GenLauncherGO.UI/Features/Integrity/IntegrityReviewDialog.axaml create mode 100644 GenLauncherGO.UI/Features/Integrity/IntegrityReviewDialog.axaml.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/Contracts/ILauncherFilePicker.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/ExecutableOption.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherCloseReason.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherContentViewKind.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/LauncherTileLinkAction.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherTileLinkKind.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherWindowContext.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/AvaloniaLauncherFilePicker.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherCloseGuard.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherContentActionCoordinator.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherExecutableSelectionService.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherGameSessionCoordinator.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/LauncherPackageActivityAdmissionService.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherTileActionService.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherWindowWorkflowCoordinator.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Support/LauncherDragDropController.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Support/LauncherWindowListController.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/ViewModels/MainWindowViewModel.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Views/LauncherContentListBox.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Views/MainWindow.axaml create mode 100644 GenLauncherGO.UI/Features/Launcher/Views/MainWindow.axaml.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 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/AddModificationItemViewModel.cs 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/Views/AddModificationWindow.axaml create mode 100644 GenLauncherGO.UI/Features/Mods/Views/AddModificationWindow.axaml.cs create mode 100644 GenLauncherGO.UI/Features/Mods/Views/InfoWindow.axaml create mode 100644 GenLauncherGO.UI/Features/Mods/Views/InfoWindow.axaml.cs create mode 100644 GenLauncherGO.UI/Features/Mods/Views/ManualAddModificationWindow.axaml create mode 100644 GenLauncherGO.UI/Features/Mods/Views/ManualAddModificationWindow.axaml.cs create mode 100644 GenLauncherGO.UI/Features/Settings/LauncherPreferencesUpdate.cs create mode 100644 GenLauncherGO.UI/Features/Settings/ViewModels/LauncherExecutableManagementViewModel.cs create mode 100644 GenLauncherGO.UI/Features/Settings/ViewModels/LauncherSettingsViewModel.cs create mode 100644 GenLauncherGO.UI/Features/Settings/Views/LauncherExecutableEditorWindow.axaml create mode 100644 GenLauncherGO.UI/Features/Settings/Views/LauncherExecutableEditorWindow.axaml.cs create mode 100644 GenLauncherGO.UI/Features/Settings/Views/LauncherExecutableManagementWindow.axaml create mode 100644 GenLauncherGO.UI/Features/Settings/Views/LauncherExecutableManagementWindow.axaml.cs create mode 100644 GenLauncherGO.UI/Features/Settings/Views/LauncherSettingsWindow.axaml create mode 100644 GenLauncherGO.UI/Features/Settings/Views/LauncherSettingsWindow.axaml.cs create mode 100644 GenLauncherGO.UI/Features/Startup/Contracts/IStandaloneStartupWorkflow.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/LauncherAvaloniaApplication.cs create mode 100644 GenLauncherGO.UI/Features/Startup/LauncherRuntimeContext.cs create mode 100644 GenLauncherGO.UI/Features/Startup/LauncherUiServiceCollectionExtensions.cs create mode 100644 GenLauncherGO.UI/Features/Startup/Models/StandaloneStartupResult.cs create mode 100644 GenLauncherGO.UI/Features/Startup/Services/AvaloniaStandaloneStartupWorkflow.cs create mode 100644 GenLauncherGO.UI/Features/Startup/Services/AvaloniaStartupDialogService.cs create mode 100644 GenLauncherGO.UI/Features/Startup/Services/LauncherRestartCoordinator.cs create mode 100644 GenLauncherGO.UI/Features/Startup/Services/LauncherShutdownCoordinator.cs create mode 100644 GenLauncherGO.UI/Features/Startup/Services/LauncherStartupCulture.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/ViewModels/LauncherGameChoiceViewModel.cs create mode 100644 GenLauncherGO.UI/Features/Startup/ViewModels/LauncherGameInstallationViewModel.cs create mode 100644 GenLauncherGO.UI/Features/Startup/ViewModels/LauncherGameSelectionViewModel.cs create mode 100644 GenLauncherGO.UI/Features/Startup/ViewModels/LauncherInstallationsViewModel.cs create mode 100644 GenLauncherGO.UI/Features/Startup/ViewModels/LauncherSetupViewModel.cs create mode 100644 GenLauncherGO.UI/Features/Startup/Views/InitWindow.axaml create mode 100644 GenLauncherGO.UI/Features/Startup/Views/InitWindow.axaml.cs create mode 100644 GenLauncherGO.UI/Features/Startup/Views/LauncherGameSelectionWindow.axaml create mode 100644 GenLauncherGO.UI/Features/Startup/Views/LauncherGameSelectionWindow.axaml.cs create mode 100644 GenLauncherGO.UI/Features/Startup/Views/LauncherLocationWarningWindow.axaml create mode 100644 GenLauncherGO.UI/Features/Startup/Views/LauncherLocationWarningWindow.axaml.cs create mode 100644 GenLauncherGO.UI/Features/Startup/Views/LauncherSetupWindow.axaml create mode 100644 GenLauncherGO.UI/Features/Startup/Views/LauncherSetupWindow.axaml.cs create mode 100644 GenLauncherGO.UI/GenLauncherGO.UI.csproj create mode 100644 GenLauncherGO.UI/Properties/AssemblyInfo.cs create mode 100644 GenLauncherGO.UI/Properties/PublishProfiles/WinX64SelfContained.pubxml 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.fa.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/Controls/LauncherLoadingIndicator.axaml create mode 100644 GenLauncherGO.UI/Shared/Controls/LauncherLoadingIndicator.axaml.cs create mode 100644 GenLauncherGO.UI/Shared/Controls/LauncherTextBoxFeedback.cs create mode 100644 GenLauncherGO.UI/Shared/Controls/LauncherWindowDragHandle.cs create mode 100644 GenLauncherGO.UI/Shared/Controls/LauncherWindowScaling.cs create mode 100644 GenLauncherGO.UI/Shared/Controls/UpdateButton.cs create mode 100644 GenLauncherGO.UI/Shared/Dialogs/AvaloniaDialog.cs create mode 100644 GenLauncherGO.UI/Shared/Errors/AvaloniaUiExceptionBoundary.cs create mode 100644 GenLauncherGO.UI/Shared/Errors/IUiExceptionBoundary.cs create mode 100644 GenLauncherGO.UI/Shared/Errors/UiOperationOutcome.cs create mode 100644 GenLauncherGO.UI/Shared/Formatting/ByteSizeFormatter.cs create mode 100644 GenLauncherGO.UI/Shared/Formatting/PackageProgressTextFormatter.cs create mode 100644 GenLauncherGO.UI/Shared/Localization/AvaloniaLauncherStringLocalizer.cs create mode 100644 GenLauncherGO.UI/Shared/Localization/ILauncherStringLocalizer.cs create mode 100644 GenLauncherGO.UI/Shared/Localization/LocExtension.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%) create mode 100644 GenLauncherGO.UI/Shared/Resources/Images/OfficialCoverGenerals.png create mode 100644 GenLauncherGO.UI/Shared/Resources/Images/OfficialCoverZeroHour.jpg create mode 100644 GenLauncherGO.UI/Shared/Themes/ColorsInfo.cs create mode 100644 GenLauncherGO.UI/Shared/Themes/GenLauncherStyles.axaml create mode 100644 GenLauncherGO.UI/Shared/Themes/LauncherThemePresets.cs create mode 100644 GenLauncherGO.UI/Shared/Themes/LauncherThemeResolver.cs create mode 100644 GenLauncherGO.UI/Shared/Themes/LauncherThemeResourceApplier.cs create mode 100644 GenLauncherGO.UI/app.manifest 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/ColorsDictionary.xaml 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 create mode 100644 NuGet.config delete mode 100644 WpfSurface.dxvk-cache create mode 100644 coverage.runsettings create mode 100644 docs/images/genlaunchergo-zero-hour.png create mode 100644 eng/coverage.proj create mode 100644 eng/mutation.proj create mode 100644 global.json 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/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..d17d1cff --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,2 @@ +[mcp_servers.avalonia-docs] +url = "https://docs-mcp.avaloniaui.net/mcp" diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 00000000..0ac8ae35 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-reportgenerator-globaltool": { + "version": "5.5.10", + "commands": [ + "reportgenerator" + ], + "rollForward": false + }, + "dotnet-stryker": { + "version": "4.16.0", + "commands": [ + "dotnet-stryker" + ], + "rollForward": false + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..decae146 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,673 @@ +root = true + +############################################################################### +# GenLauncherGO code style +# +# Sources, in precedence order: +# 1. Microsoft .NET/C# coding conventions and the Framework Design Guidelines +# naming rules. +# 2. The C# formatting options at their documented defaults, which are also +# what the wider .NET ecosystem sees. +# 3. Readability, where the guidelines are silent or explicitly neutral. +# +# Enforcement: +# Every gated rule below is an `error`, enforced by `dotnet build` through +# EnforceCodeStyleInBuild (see Directory.Build.props). Nothing here relies on +# a specific IDE, so an outside contributor using any editor gets identical +# results from the command line. +# +# Convention used in this file: +# Option keys carry the *preference* only. Severity lives exclusively in +# `dotnet_diagnostic..severity`, or in the bulk +# `dotnet_analyzer_diagnostic.category-.severity` form used by the code +# quality section. Do not use the trailing `option = value:severity` form: +# where both are present `dotnet_diagnostic` silently wins, which makes rules +# look enforced when they are inert. +# +# Deliberate opt-outs are grouped at the end of the severity section with the +# reason recorded. Anything not listed runs at its built-in default. +############################################################################### + +[*] +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 + +[*.{xaml,axaml}] +indent_size = 4 + +[*.cs] + +#### Using directives #### + +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +csharp_using_directive_placement = outside_namespace + +#### Formatting: new lines #### +# Allman braces. The documented default and the near-universal .NET style. + +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +#### Formatting: indentation #### + +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = false +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current +csharp_indent_block_contents = true +csharp_indent_braces = false + +#### Formatting: spacing #### + +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +#### Formatting: wrapping #### +# Braces are mandatory (IDE0011), so preserving an author's single-line form is +# only ever `if (x) { return; }`, which stays readable. Keep the defaults. + +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Style: this/Me qualification #### + +dotnet_style_qualification_for_event = false +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_property = false + +#### Style: language keywords over framework types #### + +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true + +#### Style: var #### +# Explicit types except where the right-hand side already states the type. +# This follows the C# coding conventions doc. Note the dotnet/runtime repo uses +# `var` more liberally; the doc is the better citation and the clearer read. + +csharp_style_var_for_built_in_types = false +csharp_style_var_when_type_is_apparent = true +csharp_style_var_elsewhere = false + +#### Style: expression-bodied members #### +# Allowed where the body is genuinely an expression, disallowed where it hides +# a method's shape. + +csharp_style_expression_bodied_accessors = true +csharp_style_expression_bodied_constructors = false +csharp_style_expression_bodied_indexers = true +csharp_style_expression_bodied_lambdas = true +csharp_style_expression_bodied_local_functions = false +csharp_style_expression_bodied_methods = false +csharp_style_expression_bodied_operators = false +csharp_style_expression_bodied_properties = true + +#### Style: pattern matching #### + +csharp_style_pattern_matching_over_as_with_null_check = true +csharp_style_pattern_matching_over_is_with_cast_check = true +csharp_style_prefer_extended_property_pattern = true +csharp_style_prefer_not_pattern = true +csharp_style_prefer_pattern_matching = true +csharp_style_prefer_switch_expression = true + +#### Style: null checking #### + +csharp_style_conditional_delegate_call = true +csharp_style_prefer_null_check_over_type_check = true +csharp_style_throw_expression = true +dotnet_style_coalesce_expression = true +dotnet_style_null_propagation = true +dotnet_style_prefer_is_null_check_over_reference_equality_method = true + +#### Style: expression level #### + +# Only fires where the target type is already spelled out on the line, so the +# type still appears exactly once. Consistent with the explicit-type preference. +csharp_style_implicit_object_creation_when_type_is_apparent = true + +dotnet_style_collection_initializer = true +dotnet_style_object_initializer = true +dotnet_style_prefer_auto_properties = true +dotnet_style_prefer_compound_assignment = true +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_style_prefer_simplified_interpolation = true + +# Ternaries read poorly once the branches are non-trivial, and the analyzer +# cannot judge that. Prefer an explicit if/else. +dotnet_style_prefer_conditional_expression_over_assignment = false +dotnet_style_prefer_conditional_expression_over_return = false + +#### Style: collection expressions #### +# `[]` and `[1, 2, 3]` are strictly clearer than `Array.Empty()` and +# `new[] { 1, 2, 3 }`. `when_types_exactly_match` keeps the conversion to cases +# where the target type is unambiguous; see IDE0305 under opt-outs for the +# fluent form, which is deliberately excluded. + +dotnet_style_prefer_collection_expression = when_types_exactly_match + +#### Style: modifiers, fields, parameters #### + +csharp_prefer_static_local_function = true +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async +csharp_style_prefer_readonly_struct = true +csharp_style_prefer_readonly_struct_member = true +dotnet_style_readonly_field = true +dotnet_style_require_accessibility_modifiers = for_non_interface_members + +# Unused parameters mislead every future reader, not just external callers. +dotnet_code_quality_unused_parameters = all + +#### Style: code block and namespace shape #### + +csharp_prefer_braces = true +csharp_prefer_simple_using_statement = true +csharp_style_namespace_declarations = file_scoped +csharp_style_prefer_method_group_conversion = true +dotnet_style_namespace_match_folder = true + +# Primary constructors on non-record classes hide the field declarations and +# silently capture parameters for the object's lifetime. Declare fields. +csharp_style_prefer_primary_constructors = false + +############################################################################### +# Naming +# +# Framework Design Guidelines naming conventions. Rules are matched by symbol +# specificity rather than file order, but they are still listed most-specific +# first so the intent survives a move to older tooling. +############################################################################### + +#### Styles #### + +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.camel_case.capitalization = camel_case + +dotnet_naming_style.underscore_camel_case.required_prefix = _ +dotnet_naming_style.underscore_camel_case.capitalization = camel_case + +dotnet_naming_style.prefix_interface_with_i.required_prefix = I +dotnet_naming_style.prefix_interface_with_i.capitalization = pascal_case + +dotnet_naming_style.prefix_type_parameter_with_t.required_prefix = T +dotnet_naming_style.prefix_type_parameter_with_t.capitalization = pascal_case + +dotnet_naming_style.suffix_async.required_suffix = Async +dotnet_naming_style.suffix_async.capitalization = pascal_case + +#### Rules #### + +# Constants, including private ones, are PascalCase rather than _camelCase. +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 = error +dotnet_naming_symbols.constants.applicable_kinds = field +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_rule.local_constants_are_pascal_case.symbols = local_constants +dotnet_naming_rule.local_constants_are_pascal_case.style = pascal_case +dotnet_naming_rule.local_constants_are_pascal_case.severity = error +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +# Only catches methods declared `async`. A Task-returning method without the +# keyword cannot be matched by a naming rule, because rules cannot inspect +# return types. That gap needs CA-family analysis, not this file. +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 = error +dotnet_naming_symbols.async_methods.applicable_kinds = method +dotnet_naming_symbols.async_methods.required_modifiers = async + +dotnet_naming_rule.local_functions_are_pascal_case.symbols = local_functions +dotnet_naming_rule.local_functions_are_pascal_case.style = pascal_case +dotnet_naming_rule.local_functions_are_pascal_case.severity = error +dotnet_naming_symbols.local_functions.applicable_kinds = local_function + +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 = error +dotnet_naming_symbols.interfaces.applicable_kinds = interface + +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 = error +dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter + +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 = error +dotnet_naming_symbols.types.applicable_kinds = class, struct, enum, delegate + +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 = error +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 = error +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 = error +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +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 = error +dotnet_naming_symbols.parameters.applicable_kinds = parameter + +dotnet_naming_rule.locals_are_camel_case.symbols = locals +dotnet_naming_rule.locals_are_camel_case.style = camel_case +dotnet_naming_rule.locals_are_camel_case.severity = error +dotnet_naming_symbols.locals.applicable_kinds = local + +############################################################################### +# Severities +# +# Everything here is an error so that a violation fails `dotnet build` for +# every contributor and every agent, with no IDE and no extra tooling. +############################################################################### + +#### Formatting #### +# IDE0055 is the umbrella for every csharp_space_*/new_line_*/indent_* option +# above, which is what makes formatting a build error rather than something +# only `dotnet format` would notice. +dotnet_diagnostic.IDE0055.severity = error + +#### Naming #### +dotnet_diagnostic.IDE1006.severity = error + +#### Unnecessary code #### +dotnet_diagnostic.IDE0005.severity = error +dotnet_diagnostic.IDE0035.severity = error +dotnet_diagnostic.IDE0051.severity = error +dotnet_diagnostic.IDE0052.severity = error +dotnet_diagnostic.IDE0059.severity = error +dotnet_diagnostic.IDE0060.severity = error +dotnet_diagnostic.IDE0080.severity = error +dotnet_diagnostic.IDE0100.severity = error +dotnet_diagnostic.IDE0110.severity = error + +#### Code block and namespace shape #### +dotnet_diagnostic.IDE0011.severity = error +dotnet_diagnostic.IDE0040.severity = error +dotnet_diagnostic.IDE0044.severity = error +dotnet_diagnostic.IDE0063.severity = error +dotnet_diagnostic.IDE0065.severity = error +dotnet_diagnostic.IDE0130.severity = error +dotnet_diagnostic.IDE0161.severity = error + +#### this/Me qualification and type references #### +dotnet_diagnostic.IDE0003.severity = error +dotnet_diagnostic.IDE0009.severity = error +dotnet_diagnostic.IDE0049.severity = error + +#### var #### +dotnet_diagnostic.IDE0007.severity = error +dotnet_diagnostic.IDE0008.severity = error + +#### Expression-bodied members #### +dotnet_diagnostic.IDE0021.severity = error +dotnet_diagnostic.IDE0022.severity = error +dotnet_diagnostic.IDE0023.severity = error +dotnet_diagnostic.IDE0024.severity = error +dotnet_diagnostic.IDE0025.severity = error +dotnet_diagnostic.IDE0026.severity = error +dotnet_diagnostic.IDE0027.severity = error +dotnet_diagnostic.IDE0053.severity = error +dotnet_diagnostic.IDE0061.severity = error + +#### Pattern matching #### +dotnet_diagnostic.IDE0019.severity = error +dotnet_diagnostic.IDE0020.severity = error +dotnet_diagnostic.IDE0038.severity = error +dotnet_diagnostic.IDE0066.severity = error +dotnet_diagnostic.IDE0078.severity = error +dotnet_diagnostic.IDE0083.severity = error +dotnet_diagnostic.IDE0170.severity = error + +#### Null checking #### +dotnet_diagnostic.IDE0016.severity = error +dotnet_diagnostic.IDE0029.severity = error +dotnet_diagnostic.IDE0030.severity = error +dotnet_diagnostic.IDE0031.severity = error +dotnet_diagnostic.IDE0041.severity = error +dotnet_diagnostic.IDE0150.severity = error +dotnet_diagnostic.IDE0270.severity = error +dotnet_diagnostic.IDE1005.severity = error + +#### Expression level #### +dotnet_diagnostic.IDE0017.severity = error +dotnet_diagnostic.IDE0018.severity = error +dotnet_diagnostic.IDE0032.severity = error +dotnet_diagnostic.IDE0033.severity = error +dotnet_diagnostic.IDE0034.severity = error +dotnet_diagnostic.IDE0037.severity = error +dotnet_diagnostic.IDE0039.severity = error +dotnet_diagnostic.IDE0054.severity = error +dotnet_diagnostic.IDE0056.severity = error +dotnet_diagnostic.IDE0057.severity = error +dotnet_diagnostic.IDE0071.severity = error +dotnet_diagnostic.IDE0074.severity = error +dotnet_diagnostic.IDE0075.severity = error +dotnet_diagnostic.IDE0082.severity = error +dotnet_diagnostic.IDE0090.severity = error +dotnet_diagnostic.IDE0180.severity = error +dotnet_diagnostic.IDE0200.severity = error +dotnet_diagnostic.IDE0330.severity = error + +#### Collection expressions #### +dotnet_diagnostic.IDE0028.severity = error +dotnet_diagnostic.IDE0300.severity = error +dotnet_diagnostic.IDE0301.severity = error +dotnet_diagnostic.IDE0302.severity = error +dotnet_diagnostic.IDE0303.severity = error +dotnet_diagnostic.IDE0304.severity = error +dotnet_diagnostic.IDE0306.severity = error + +#### Modifiers #### +dotnet_diagnostic.IDE0036.severity = error +dotnet_diagnostic.IDE0062.severity = error +dotnet_diagnostic.IDE0064.severity = error +dotnet_diagnostic.IDE0250.severity = error +dotnet_diagnostic.IDE0251.severity = error + +#### XML documentation #### +# Documentation is required for cross-project contracts and non-obvious +# behavior, not for every member, but what exists must be valid. +dotnet_diagnostic.CS1570.severity = error +dotnet_diagnostic.CS1571.severity = error +dotnet_diagnostic.CS1572.severity = error +dotnet_diagnostic.CS1573.severity = error +dotnet_diagnostic.CS1574.severity = error + +#### Deliberate opt-outs #### + +# IDE0305 rewrites `source.ToArray()` as `[.. source]`. The spread form is +# harder to read than the call it replaces, which loses on the readability +# tiebreaker even though the sibling collection-expression rules win on it. +dotnet_diagnostic.IDE0305.severity = none + +# IDE0042 pushes `var (name, value) = pair`, which hides both types and +# contradicts the explicit-type preference set above. +dotnet_diagnostic.IDE0042.severity = none + +# IDE0058 flags every ignored return value, including the fluent builder and +# TryAdd patterns where discarding is intended. Too noisy to gate on. +dotnet_diagnostic.IDE0058.severity = none + +# IDE0046/IDE0045 are the diagnostics behind the two +# prefer_conditional_expression preferences disabled above. +dotnet_diagnostic.IDE0045.severity = none +dotnet_diagnostic.IDE0046.severity = none + +# IDE0290 is the primary-constructor suggestion, disabled by preference above. +dotnet_diagnostic.IDE0290.severity = none + +############################################################################### +# Code quality +# +# Which categories run is selected in Directory.Build.props. The bulk +# `dotnet_analyzer_diagnostic.category-*` entries here raise every rule in those +# categories to `error`: enabling a category leaves each rule at its own default +# severity, and several default to a suggestion, which would be enabled but not +# gating. +# +# Design and Naming deliberately keep their default rule sets instead. Several +# of their rules are written for reusable libraries and contradict this +# repository's own gates: CA1032 and CA1064 require members nothing calls, which +# the unused-public-API rule forbids; CA1014 is meaningless without external +# consumers; CA1062 is redundant under `Nullable=enable`; CA1031 and CA1060 +# would force 95 boundary catches and four P/Invoke files to be restructured for +# a legacy convention. The rules worth having are opted into individually. +############################################################################### + +#### Enabled categories #### +dotnet_analyzer_diagnostic.category-Documentation.severity = error +dotnet_analyzer_diagnostic.category-Globalization.severity = error +dotnet_analyzer_diagnostic.category-Interoperability.severity = error +dotnet_analyzer_diagnostic.category-Maintainability.severity = error +dotnet_analyzer_diagnostic.category-Reliability.severity = error +dotnet_analyzer_diagnostic.category-Security.severity = error +dotnet_analyzer_diagnostic.category-SingleFile.severity = error +dotnet_analyzer_diagnostic.category-Usage.severity = error + +#### Design opt-ins #### +dotnet_diagnostic.CA1052.severity = error +dotnet_diagnostic.CA1058.severity = error +dotnet_diagnostic.CA1061.severity = error +dotnet_diagnostic.CA1063.severity = error +dotnet_diagnostic.CA1065.severity = error +dotnet_diagnostic.CA1068.severity = error +dotnet_diagnostic.CA1069.severity = error + +#### Naming opt-ins #### +# IDE1006 already covers casing and prefixes. These add what a naming style +# cannot express: underscores and enum member prefixes. +dotnet_diagnostic.CA1707.severity = error +dotnet_diagnostic.CA1712.severity = error + +#### Performance opt-ins #### +# The full Performance set is mostly micro-optimization that a launcher cannot +# measure — char overloads of string methods and similar — where each rule buys a +# build break for a stylistic preference. These two are different: both describe +# defects rather than inefficiencies. +# +# CA1851 catches enumerating a lazy sequence more than once, which changes results +# as well as cost. CA1862 catches case-insensitive comparison written without a +# StringComparison, the classic source of Windows path and culture bugs. +dotnet_diagnostic.CA1851.severity = error +dotnet_diagnostic.CA1862.severity = error + +#### Deliberate opt-outs #### + +# CA1848 (LoggerMessage delegates) and CA1873 (guard log calls with IsEnabled) +# are throughput rules for high-volume server logging. This launcher writes a +# handful of lines per user action, so both trade readable diagnostics for an +# unmeasurable gain: CA1873 alone would wrap 100 call sites in IsEnabled checks +# to avoid a Path.GetFileName. +dotnet_diagnostic.CA1848.severity = none +dotnet_diagnostic.CA1873.severity = none + +# CA1859 asks for concrete types in place of interfaces for speed. The gain is +# unmeasurable in a launcher and the change widens coupling to implementations, +# against the interface-boundary rule in AGENTS.md. +dotnet_diagnostic.CA1859.severity = none + +# The rules below cannot see enough to judge the code they flag, so they bill +# their cost as a growing list of per-site exceptions. A rule that has to be +# argued with at each call site is not a gate, so they are off rather than +# carried with carve-outs. +# +# CA1812 reports types that nothing constructs in source. The YAML and JSON +# readers construct their bound types through reflection, which it cannot see, +# so it reports live binding fixtures as dead code. +dotnet_diagnostic.CA1812.severity = none + +# CA2000 cannot follow dispose ownership once it leaves the creating method, and +# its own message documents the workarounds that fact requires. It reported the +# deliberately long-lived shared HttpClient and cached theme bitmap alongside a +# genuine leak, at a roughly even rate. The real leaks it found were fixed; the +# rule is off because sorting its output is a per-site argument, not a gate. +# CA2025, which is precise about a narrower hazard, stays on. +dotnet_diagnostic.CA2000.severity = none + +# CA2213 shares that blind spot for fields. Its only finding here was the logger +# factory resolved from the service provider, which the provider disposes; doing +# it again in the holding type would be a double dispose asserting an ownership +# the type does not have. +dotnet_diagnostic.CA2213.severity = none + +# The rules below ask for changes that cost more than the finding is worth here. +# Each was measured against this codebase rather than judged on reputation, and +# each would trade a real guarantee for a benefit this project does not collect. +# +# CA1849 wants FileStream.Flush(true) replaced with FlushAsync(). They are not +# equivalent: Flush(true) forces the operating system buffer to disk, which is +# the durability step the atomic writer exists to provide, and FlushAsync() does +# not. The sync-over-async calls it correctly identified elsewhere were fixed +# before switching it off. +dotnet_diagnostic.CA1849.severity = none + +# SYSLIB1054 and SYSLIB1096 convert DllImport and ComImport to the source +# generated forms. Both change marshalling behavior, and the code they land on is +# the hard-link, process-launch, and shell interop that AGENTS.md calls out as +# safety critical. Their payoff is trimming and AOT compatibility, which this +# launcher does not use: the supported publish sets PublishTrimmed=false. +dotnet_diagnostic.SYSLIB1054.severity = none +dotnet_diagnostic.SYSLIB1096.severity = none + +# CA1515 would make Avalonia controls, a XAML markup extension, and the generated +# Strings.cs internal. That risks breaking XAML type resolution and test +# discovery, and editing a generated designer file is forbidden by the UI +# guidance in any case. +dotnet_diagnostic.CA1515.severity = none + +# CA2007 breaks `dotnet format`. Its code fix appends .ConfigureAwait(false) to +# the initializer of an `await using` declaration, where the diagnostic is really +# about the disposal; that loses the target type of `new(...)` and changes the +# variable's type, so a plain `dotnet format` run left seven files uncompilable. +# A rule whose own fixer corrupts the source on a standard command is a trap for +# anyone contributing, whatever its merits. The ConfigureAwait calls it correctly +# prompted in Core and Infrastructure were kept. +dotnet_diagnostic.CA2007.severity = none + +# CA2016 breaks `dotnet format` the same way. Asked to forward a cancellation +# token into `Task.Run(() => ...)` inside a test that has no token in scope, its +# fixer writes `_` as the argument, which is not an identifier that exists there, +# and the file stops compiling. +dotnet_diagnostic.CA2016.severity = none + +# CA1826 replaces `list.FirstOrDefault()` with a `Count > 0 ? list[0] : null` +# conditional to save an enumerator allocation. Across the twelve call sites here +# that is a plainly worse read for a cost this launcher cannot measure, and most +# of them continue into `?.Something`, which the rewrite makes harder to follow. +dotnet_diagnostic.CA1826.severity = none + +# CA1806 reports an ignored return value for `Action act = () => new Thing(...)`, +# the standard way to assert that a constructor rejects its arguments. The value +# is meant to be discarded; the throw is the subject of the test. +dotnet_diagnostic.CA1806.severity = none + +# CA1838 wants the StringBuilder out of the GetFinalPathNameByHandle signature for +# cheaper marshalling. That buffer backs the path-safety primitives, and the same +# reasoning as SYSLIB1054 applies: a marshalling change to containment interop for +# a cost this launcher never pays. +dotnet_diagnostic.CA1838.severity = none + +# CA1822 is the most expensive rule measured here, and none of the cost is in the +# code it flags. Marking a member static breaks every instance-qualified call +# site, including ones in other projects, and the analyzer reports none of them: +# adopting it produced four rounds of CS0176 across the solution, one of which hid +# every diagnostic in two projects until it was cleared. A launcher collects +# nothing measurable in return. Held off rather than paid for repeatedly. +dotnet_diagnostic.CA1822.severity = none + +# CA1725 requires a parameter name to match the base declaration, which means +# adopting whatever the framework chose even when it reads worse. It renamed +# `eventArgs` to `e` on an override, and consistency with a single-letter name is +# not worth a less descriptive signature. +dotnet_diagnostic.CA1725.severity = none + + +# CA1308 prefers uppercase normalization for round-trip safety. It cannot tell +# that LauncherContentKey.ToStableString formats an identity already persisted +# in launcher-owned integrity records, where changing case invalidates every +# existing record. +dotnet_diagnostic.CA1308.severity = none + +# CA5394 reports every use of Random. It cannot tell that the only use here +# picks which advertising banner a mod tile shows. +dotnet_diagnostic.CA5394.severity = none + +# CA5351 reports every use of MD5. The one use here is fixed by the remote +# manifest contract, because S3 ETags are MD5, and detects an incomplete or +# corrupted download rather than guarding a security boundary. Everything else +# hashes with SHA-256. Note the cost of switching this off: a genuine MD5 +# misuse added later is no longer caught, so this is the one exclusion worth +# revisiting if the launcher ever hashes for authentication. +dotnet_diagnostic.CA5351.severity = none + +############################################################################### +# Project-scoped exceptions +# +# These draw a boundary between projects, which is a fact about the architecture +# rather than a per-site argument with the rule. A contributor reads them once. +############################################################################### + +[GenLauncherGO.Tests/**.cs] + +# These xUnit rules define whether a test is discoverable and distinct. Most are +# warnings already promoted by TreatWarningsAsErrors; listing them explicitly +# keeps the test contract stable if an analyzer changes its default severity. +dotnet_diagnostic.xUnit1004.severity = error +dotnet_diagnostic.xUnit1013.severity = error +dotnet_diagnostic.xUnit1025.severity = error +dotnet_diagnostic.xUnit1026.severity = error + +# GLT001 requires `MemberOrBehavior_ExpectedOutcome` or +# `MemberOrBehavior_Scenario_ExpectedOutcome` for every xUnit test method. +dotnet_diagnostic.GLT001.severity = error + +# Behavior-oriented test names use underscores to separate their semantic parts. +dotnet_diagnostic.CA1707.severity = none + +# CA1861 hoists inline arrays into `static readonly` fields to avoid reallocating +# them, which it qualifies with "if the called method is called repeatedly". A +# test method runs once, so the trade is all cost: the expected values move away +# from the assertion that reads them. +dotnet_diagnostic.CA1861.severity = none + +############################################################################### +# File-scoped exceptions +############################################################################### + +# This transport type mirrors the legacy backend's YAML keys character for +# character, so its members cannot follow the naming rules. Scoped here rather +# than with a #pragma because Rider honors an .editorconfig severity but not the +# pragma, which otherwise leaves the rule looking violated in the IDE while the +# build is clean. +[GenLauncherGO.Infrastructure/Mods/Models/LegacyLauncherCatalogDocument.cs] +dotnet_diagnostic.IDE1006.severity = none diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..0fcea614 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# Line endings are declared in .editorconfig (end_of_line = crlf) and enforced +# on C# by `dotnet format whitespace`. Without this file the working-tree result +# depends on each contributor's core.autocrlf setting, so the same checkout can +# pass locally and fail the format gate in CI. +# +# Everything text-like is stored as LF and checked out as CRLF, which matches +# what the index already contains, so this changes no stored content. +* text=auto eol=crlf + +# Binary assets must never be normalized or diffed as text. +*.png binary +*.jpg binary +*.ico binary 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/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..dc090e10 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,50 @@ +# Package versions are centralized in Directory.Packages.props, which Dependabot +# updates in place. Two things here are deliberately not covered and stay manual: +# the SDK pin in global.json, and the tool pin in .config/dotnet-tools.json. +# +# Updates are grouped so a framework arrives as one reviewable pull request +# rather than one per package. Every pull request still has to clear the full +# build, which treats style, naming, and code quality violations as errors, so a +# bump that introduces a new diagnostic will fail until it is dealt with. +version: 2 + +updates: + - package-ecosystem: nuget + directory: "/" + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + commit-message: + prefix: chore + include: scope + groups: + avalonia: + patterns: + - "Avalonia*" + serilog: + patterns: + - "Serilog*" + microsoft-extensions: + patterns: + - "Microsoft.Extensions.*" + test-tooling: + patterns: + - "coverlet.*" + - "FluentAssertions" + - "Microsoft.NET.Test.Sdk" + - "NSubstitute*" + - "xunit*" + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: monday + commit-message: + prefix: ci + include: scope + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..9f61404e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,126 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + # Normalizes source paths in the produced assemblies, which is what makes the + # Deterministic setting in Directory.Build.props reproducible off this machine. + ContinuousIntegrationBuild: true + +jobs: + build-and-test: + name: Build, test, and verify coverage + runs-on: windows-2025 + timeout-minutes: 20 + env: + GENLAUNCHERGO_REQUIRE_SYMBOLIC_LINK_TESTS: true + + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + with: + global-json-file: global.json + + - name: Restore + run: dotnet restore .\GenLauncherGO.sln + + # Style, naming, and code quality rules are `error` in .editorconfig and + # run through EnforceCodeStyleInBuild, so the build itself is the primary + # gate. + - name: Build + run: dotnet build .\GenLauncherGO.sln --configuration Release --no-restore + + # Runs the whitespace, style, and analyzer formatters. Not redundant with + # the build: whitespace and end-of-line violations are reported here only, + # as are style fixers that have no build-time implementation. Configuration + # independent, so once is enough. + - name: Verify formatting + run: dotnet format .\GenLauncherGO.sln --verify-no-changes --no-restore + + - name: Test with coverage backstop + run: dotnet msbuild .\eng\coverage.proj -target:Coverage -property:Configuration=Release -property:SkipCoverageBuild=true + + # Mutation testing is the gate for whether behavior is actually asserted; the coverage + # backstop above only proves a line executed. One job per production area: fanning them out + # keeps each area's break threshold enforceable on its own and keeps wall-clock bounded, and + # fail-fast is off so a regression in one area does not hide the state of the others. + # + # GenLauncherGO.UI is absent by necessity, not oversight. Avalonia emits InitializeComponent + # and the x:Name backing fields from a Roslyn source generator; Stryker recompiles from + # parsed syntax trees without running generators, so every .axaml.cs fails to compile and the + # run aborts. See eng/mutation.proj. + mutation-quality: + name: Mutation quality (${{ matrix.area }}) + runs-on: windows-2025 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + area: + - core + - infrastructure-common + - infrastructure-integrity + - infrastructure-launching + - infrastructure-mods + - infrastructure-platform + - infrastructure-updating + env: + GENLAUNCHERGO_REQUIRE_SYMBOLIC_LINK_TESTS: true + + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + with: + global-json-file: global.json + + - name: Restore + run: dotnet restore .\GenLauncherGO.sln + + - name: Test mutations + run: dotnet msbuild .\eng\mutation.proj -target:Mutation -property:MutationConfiguration=${{ matrix.area }} + + publish-smoke-test: + name: Publish smoke test + runs-on: windows-2025 + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + with: + global-json-file: global.json + + - name: Publish supported single-file executable + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP "GenLauncherGO-publish" + dotnet publish .\GenLauncherGO.UI\GenLauncherGO.UI.csproj -p:PublishProfile=WinX64SelfContained --output $publishDirectory + $launcherPath = Join-Path $publishDirectory "GenLauncherGO.exe" + if (-not (Test-Path -LiteralPath $launcherPath -PathType Leaf)) { + throw "Expected the supported launcher executable at $launcherPath." + } + $looseLibraries = @(Get-ChildItem -LiteralPath $publishDirectory -Filter "*.dll" -File -Recurse) + if ($looseLibraries.Count -ne 0) { + throw "The supported single-file publish unexpectedly produced loose DLL files." + } diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml new file mode 100644 index 00000000..82055f56 --- /dev/null +++ b/.github/workflows/dependency-audit.yml @@ -0,0 +1,58 @@ +name: Dependency audit + +on: + schedule: + # Weekly, so advisory checks run without waiting for a code change. + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + +jobs: + dependency-audit: + name: Dependency audit + runs-on: windows-2025 + timeout-minutes: 15 + + # NU1901-NU1904 are downgraded to warnings in Directory.Build.props so that a + # newly published advisory cannot fail an unrelated pull request. This job is + # the other half of that decision: it fails loudly, on a schedule, so the + # findings are acted on rather than merely visible. + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + with: + global-json-file: global.json + + - name: Restore + run: dotnet restore .\GenLauncherGO.sln + + - name: Fail on vulnerable packages + shell: pwsh + run: | + $report = dotnet list .\GenLauncherGO.sln package --vulnerable --include-transitive | Out-String + Write-Host $report + if ($report -match "has the following vulnerable packages") { + throw "Vulnerable packages detected. See the report above." + } + + - name: Report deprecated packages + shell: pwsh + run: | + $report = dotnet list .\GenLauncherGO.sln package --deprecated | Out-String + Write-Host $report + if ($report -match "has the following deprecated packages") { + throw "Deprecated packages detected. See the report above." + } diff --git a/.gitignore b/.gitignore index 4f3be99f..c6e49d9c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,398 +1,35 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore +# .NET and Avalonia build/publish output +[Bb]in/ +[Oo]bj/ +/artifacts/ +/publish/ +*.binlog + +# Test results and coverage output +/TestResults/ +/GenLauncherGO.Tests/StrykerOutput/ +*.trx +*.coverage +*.coveragexml -# User-specific files +# Visual Studio +/.vs/ *.rsuser *.suo *.user *.userosscache *.sln.docstates -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper +# JetBrains Rider/ReSharper +/.idea/ +_ReSharper.Caches/ +*.sln.iml *.DotSettings.user -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* +# Local editor and user files +*.userprefs *~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 auto-generated project file (contains which files were open etc.) -*.vbp - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# Local History for Visual Studio Code -.history/ - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp +~$* -# JetBrains Rider -*.sln.iml \ No newline at end of file +# Local code indexes +/.codegraph/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..2be80f2c --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "avalonia-docs": { + "type": "http", + "url": "https://docs-mcp.avaloniaui.net/mcp" + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..4ca18d70 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,62 @@ +# GenLauncherGO Agent Guidelines + +GenLauncherGO is a small Windows launcher with a native Avalonia UI for Generals and Zero Hour community clients. + +## Workflow + +- Preserve existing behavior unless the user explicitly requests a change. +- Preserve user changes; avoid unrelated cleanup. +- Search for the current owner and callers before adding or replacing shared behavior. +- For Avalonia or WPF-to-Avalonia work, use the repository-configured `avalonia-docs` MCP server and load its expert rules first. +- Use only free Avalonia tooling: the Build MCP documentation, expert-rule, API, mapping, and native-migration tools, the open-source framework, and free legacy tooling. Do not call `migrate_diagnostics` or `recreate-ui`, and skip any Developer Tools setup suggested by `new`. Do not configure the Developer Tools application, DevTools MCP, Avalonia XPF, or another commercial feature unless the owner supplies a license and requests it. +- Verify with `dotnet build GenLauncherGO.sln`, `dotnet format GenLauncherGO.sln --verify-no-changes`, and `dotnet test GenLauncherGO.sln`. Use narrow commands while iterating, then all three across the solution before handoff. +- Trust a diagnostic count only once the build reports no compiler errors. A project that fails to compile reports nothing of its own and hides every diagnostic in the projects downstream of it. +- Apply a bulk fix only at the sites the tool reported, never file-wide, then rebuild the whole solution. A change that compiles where you made it can still break callers in another project, and `dotnet format` rewrites code rather than only whitespace. + +## Project Boundaries + +| Project | Owns | +| --- | --- | +| `GenLauncherGO.Core/` | Domain rules, values, validation, intentional cross-project contracts | +| `GenLauncherGO.Infrastructure/` | Disk, network, archives, processes, hashing, persistence, logging adapters | +| `GenLauncherGO.UI/` | Native Avalonia presentation and the composition root | +| `GenLauncherGO.Tests/` | Observable behavior, safety, compatibility, and invariant tests | + +Read the nearest nested `AGENTS.md` before editing a project. There is intentionally no `src/` folder. + +## Design Gates + +- Optimize for a small launcher: prefer direct calls and concrete `internal sealed` types. +- Core has no external consumers. Do not keep unused public APIs, old names, adapters, or compatibility shims. +- Maintain one authority for content identity, executable names, type mapping, owned paths, settings, and other shared rules. Reuse or move it; never copy it. +- Do not add mediator, CQRS, service-locator, or similar frameworks. +- Do not add speculative extension points or edge cases; require current behavior, an external contract, a reproduced defect, or a safety invariant. +- Keep production code feature-first. Do not add a folder or layer for file count, symmetry, or anticipated growth. +- Fixed arguments, localization keys, or one forwarded call do not justify a type. + +| New artifact | Allowed only when | +| --- | --- | +| Interface | It is an external or side-effect boundary, or has multiple production implementations. Testing convenience alone is insufficient. | +| Request | It validates a stable operation boundary or is genuinely shared; never just bundle arguments for one internal call. | +| Result | Callers branch on named outcomes or need structured failure data; never just mirror returned properties. | +| Factory | It selects implementations or owns meaningful construction or lifetime policy; never merely call `new`. | +| Coordinator | It owns sequencing, state, rollback, or lifecycle; never merely forward calls or group dependencies. | +| Mapper or DTO | It crosses an external or persistence boundary. Map once; do not add an intermediate mirror model. | +| Wrapper | It adds an invariant, ownership, or policy. Otherwise call the existing type directly. | + +## Non-Negotiable Constraints + +- The remote YAML/backend contract is external. Preserve its accepted keys, shapes, defaults, and semantics at the Infrastructure boundary. +- Launch preparation mutates a user's game folder. Preserve ownership, containment, rollback, and recovery defenses. +- Fix style, naming, quality, and formatting violations in the code. Never make a change compile or a test pass by weakening a gate: no new `.editorconfig` severity downgrade or opt-out, no suppression or `#pragma`, no `NoWarn`, no analyzer or warnings-as-errors property change, no skipped or deleted test. If a gate is genuinely wrong, say so and stop. +- A rule earns removal rather than exceptions when it reports false positives, its fixer corrupts source, or its remedy costs more than the defect it names. Scoping a rule to a project is fine; a growing list of per-site carve-outs is not. Record the reason beside the exclusion in `.editorconfig`, and raise removal as its own decision rather than as a way past the violation in front of you. +- Document cross-project contracts and non-obvious side effects, invariants, compatibility constraints, or platform behavior. Do not document obvious implementation details. +- Use `GenLauncherGO` for new names. Do not add a license or release/deployment automation without an explicit owner decision. + +## Completion + +- Remove superseded code in the same change; do not leave parallel paths without a current caller. +- Inspect the final diff for duplicate logic, avoidable types, widened visibility, and tests coupled to implementation details. +- In the handoff, list every new production interface/request/result/factory/coordinator/wrapper and the gate that justified it; say explicitly when none were added. +- Report reused or changed canonical authorities and all verification run. +- Use Conventional Commits when committing: `type(scope): short imperative summary`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..5869473b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,125 @@ +# Contributing to GenLauncherGO + +Contributions are welcome. Fork the repository, create a branch, make and test your changes, then open a pull request +with a clear description. + +## Development setup + +The repository selects the .NET 10 SDK through `global.json`, starting at version `10.0.300` and allowing later +feature bands. + +Run the Avalonia project from the repository root: + +```powershell +dotnet run --project ./GenLauncherGO.UI/GenLauncherGO.UI.csproj +``` + +For a full launcher UI session, run the executable outside all supported game installations. Startup validation +blocks the launcher when it is placed inside one. + +## Required quality gates + +Run the standard repository checks before submitting a change: + +```powershell +dotnet build GenLauncherGO.sln +dotnet format GenLauncherGO.sln --verify-no-changes +dotnet test GenLauncherGO.sln +``` + +The Windows CI workflow builds Release, verifies formatting, runs the complete test suite with coverage thresholds, +checks each mutation-test area, and verifies the supported single-file publish profile. A separate weekly workflow +audits vulnerable and deprecated dependencies. + +## Symbolic-link safety tests + +Symbolic-link tests are required in CI and fail the workflow if the runner cannot execute them. Local accounts that +cannot create symbolic links report those tests as explicit skips. To enforce the same fail-closed behavior locally, +enable Windows Developer Mode or use an elevated terminal, then run: + +```powershell +$env:GENLAUNCHERGO_REQUIRE_SYMBOLIC_LINK_TESTS = "true" +dotnet test GenLauncherGO.sln +Remove-Item Env:GENLAUNCHERGO_REQUIRE_SYMBOLIC_LINK_TESTS +``` + +## Coverage and mutation testing + +Generate a local coverage report: + +```powershell +dotnet msbuild ./eng/coverage.proj -target:Coverage +``` + +The HTML report is written to `artifacts/coverage/index.html`. Coverage thresholds catch missing execution; they do +not measure assertion quality. + +Mutation-test the domain and infrastructure projects: + +```powershell +dotnet msbuild ./eng/mutation.proj -target:Mutation +``` + +Run one area instead of all seven: + +```powershell +dotnet msbuild ./eng/mutation.proj -target:Mutation -property:MutationConfiguration=infrastructure-launching +``` + +Mutation testing is the gate for whether behavior is meaningfully asserted. It covers every behavior-bearing file in +`GenLauncherGO.Core` and `GenLauncherGO.Infrastructure`, split across `core`, `infrastructure-common`, +`infrastructure-integrity`, `infrastructure-launching`, `infrastructure-mods`, `infrastructure-platform`, and +`infrastructure-updating`. Each area has its own break threshold, so a strongly covered area cannot hide a weaker one. +CI runs these areas as a matrix. + +`GenLauncherGO.UI` is not mutation-tested because Avalonia emits `InitializeComponent` and the `x:Name` backing +fields through a Roslyn source generator. Stryker rebuilds from parsed syntax trees without running generators, so +the generated members are unavailable and the run aborts before testing anything. UI quality is enforced through the +coverage backstop and behavioral tests instead. + +Some mutants survive intentionally. Exception-message text, `ConfigureAwait`, durability flags such as `Flush(true)`, +buffer sizes, and argument guards are either unobservable through behavior tests or excluded by the test guidance. +The Stryker configuration filters what it can, and the thresholds account for the remainder. + +## Publishing + +Publish the supported self-contained, single-file Windows x64 executable: + +```powershell +dotnet publish ./GenLauncherGO.UI/GenLauncherGO.UI.csproj -p:PublishProfile=WinX64SelfContained -o ./publish +``` + +Ordinary Debug and Release builds are framework-dependent and do not select a runtime. The explicit +`WinX64SelfContained` profile produces the supported distributable, with `GenLauncherGO.exe` as the launcher +executable. + +## Architecture + +The solution deliberately uses three production projects, one test project, and one test-only analyzer project, with +no `src` folder: + +| Project | Responsibility | +| --- | --- | +| `GenLauncherGO.Core` | Dependency-light contracts, launcher rules, models, and path identities | +| `GenLauncherGO.Infrastructure` | Disk, network, archive, process, persistence, integrity, and package-provider implementations | +| `GenLauncherGO.UI` | Native Avalonia presentation, user workflows, localization, and the dependency-injection composition root | +| `GenLauncherGO.Tests` | Observable behavior, compatibility, recovery, and file-system safety tests | +| `GenLauncherGO.TestAnalyzers` | Test-only analyzer enforcing the repository's test-method naming convention | + +Dependencies point inward: UI can reference Core and Infrastructure, Infrastructure can reference Core, and Core does +not reference Avalonia or implementation packages. Interfaces represent intentional project or side-effect +boundaries; feature-internal code normally uses sealed concrete types. + +Mutable paths carry their owning root so file operations can reject traversal and reparse-point escapes. + +## Backend compatibility + +GenLauncherGO consumes an external backend tied to [p0ls3r](https://github.com/p0ls3r) and the original GenLauncher +project. This repository does not control that backend, so its legacy remote YAML names and structure are preserved +exactly at the Infrastructure boundary and mapped into the application's internal models. Do not rename or reshape +that manifest contract without a deliberate compatibility plan coordinated with the backend maintainers. + +## Submitting changes + +Keep changes focused, preserve existing behavior unless the change deliberately updates it, and include tests for +observable behavior or safety invariants. In the pull request, explain what changed and list the verification you ran. diff --git a/Claude.md b/Claude.md new file mode 100644 index 00000000..3d251102 --- /dev/null +++ b/Claude.md @@ -0,0 +1 @@ +Read and follow [AGENTS.md](AGENTS.md) and the nearest `AGENTS.md` in any subdirectory before working there. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..f8e7d805 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,63 @@ + + + 14.0 + enable + disable + true + + + + + 10.0 + + + true + + + true + $(NoWarn);CS1591 + + + All + All + All + All + All + All + All + All + + + true + + true + + + $(WarningsNotAsErrors);NU1900;NU1901;NU1902;NU1903;NU1904 + + + + + true + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 00000000..fe6c73a2 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,25 @@ + + + + <_DisallowedProjectReference Include="@(ProjectReference)" + Condition=" + '$(MSBuildProjectName)' == 'GenLauncherGO.Core' + Or ('$(MSBuildProjectName)' == 'GenLauncherGO.Infrastructure' + And '%(ProjectReference.Filename)' != 'GenLauncherGO.Core') + Or ('$(MSBuildProjectName)' == 'GenLauncherGO.UI' + And '%(ProjectReference.Filename)' != 'GenLauncherGO.Core' + And '%(ProjectReference.Filename)' != 'GenLauncherGO.Infrastructure') + Or ('$(MSBuildProjectName)' == 'GenLauncherGO.Tests' + And '%(ProjectReference.Filename)' != 'GenLauncherGO.Core' + And '%(ProjectReference.Filename)' != 'GenLauncherGO.Infrastructure' + And '%(ProjectReference.Filename)' != 'GenLauncherGO.UI' + And ('%(ProjectReference.Filename)' != 'GenLauncherGO.TestAnalyzers' + Or '%(ProjectReference.OutputItemType)' != 'Analyzer' + Or '%(ProjectReference.ReferenceOutputAssembly)' != 'false'))"/> + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 00000000..12739d1f --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,29 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + 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..e73adacf --- /dev/null +++ b/GenLauncherGO.Core/AGENTS.md @@ -0,0 +1,12 @@ +# GenLauncherGO.Core Guidance + +- Keep Core dependency-light and side-effect free: no Avalonia or other UI frameworks/resources, Infrastructure, Windows + APIs, disk, network, processes, archives, hashing implementations, remote DTOs, or logging packages. +- Make a type `public` only when another production project consumes it. Public means intra-solution contract, not + external compatibility. +- Model durable identity, configuration, and domain facts as immutable values when practical; mutable workflow and UI + state do not belong here. +- Keep remote YAML names and serialization shapes in Infrastructure; Core receives normalized concepts. +- Pass `CancellationToken` through new asynchronous contracts. +- Keep expected failures explicit only when callers must act on distinct outcomes; otherwise use the simplest normal + .NET mechanism. diff --git a/GenLauncherGO.Core/GenLauncherGO.Core.csproj b/GenLauncherGO.Core/GenLauncherGO.Core.csproj new file mode 100644 index 00000000..555ae434 --- /dev/null +++ b/GenLauncherGO.Core/GenLauncherGO.Core.csproj @@ -0,0 +1,5 @@ + + + net10.0 + + diff --git a/GenLauncherGO.Core/IO/LexicalPath.cs b/GenLauncherGO.Core/IO/LexicalPath.cs new file mode 100644 index 00000000..578fcff7 --- /dev/null +++ b/GenLauncherGO.Core/IO/LexicalPath.cs @@ -0,0 +1,214 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Core.IO; + +/// +/// Provides side-effect-free Windows path normalization, relative-path, and containment operations. +/// +/// +/// These operations are lexical only. Callers that traverse or mutate the filesystem must separately inspect the +/// physical path for reparse points and other unsafe entries. +/// +public static class LexicalPath +{ + /// + /// Returns a fully qualified path without a non-root trailing directory separator. + /// + public static string NormalizeFullPath(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + } + + /// + /// Determines whether two paths identify the same lexical location using Windows case semantics. + /// + /// + /// Missing or malformed paths are not equivalent. Valid paths are fully normalized first, so differences in + /// case, trailing separators, and dot segments do not affect the comparison. + /// + public static bool AreEquivalent(string? left, string? right) + { + if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right)) + { + return false; + } + + try + { + return AreNormalizedPathsEquivalent(NormalizeFullPath(left), NormalizeFullPath(right)); + } + catch (Exception exception) when (exception is ArgumentException or IOException or NotSupportedException) + { + return false; + } + } + + /// + /// Normalizes a relative path to slash separators for persisted metadata and comparisons. + /// + public static string NormalizeRelativePath(string path) + { + ArgumentNullException.ThrowIfNull(path); + + return path.Replace('\\', '/').Trim('/'); + } + + /// + /// Gets a normalized slash-separated path from a root to another path. + /// + /// + /// The returned path can identify the root itself or leave it. Call or a + /// containment operation when a caller requires an ownership boundary. + /// + public static string GetRelativePath(string root, string path) + { + return NormalizeRelativePath(Path.GetRelativePath( + NormalizeFullPath(root), + NormalizeFullPath(path))); + } + + /// + /// Resolves a path against a root without inspecting the physical filesystem. + /// + /// + /// Rooted or traversing inputs can resolve outside . Use + /// when containment is required. + /// + public static string ResolvePath(string root, string path) + { + ArgumentNullException.ThrowIfNull(path); + + return NormalizeFullPath(Path.Combine( + NormalizeFullPath(root), + path.Replace('/', Path.DirectorySeparatorChar))); + } + + /// + /// Resolves a path against a root and proves the result stays inside it. + /// + /// The directory the resolved path must remain within. + /// The relative or absolute path to resolve. + /// The message describing what escaped which boundary. + /// + /// This is the single place that pairs resolution with its containment proof. Resolving without the proof + /// is what lets a traversing or rooted input escape an owned directory, so callers that need the boundary + /// enforced should reach for this rather than combining and + /// themselves. + /// + /// Thrown when the resolved path leaves . + public static string ResolveContainedPath(string root, string path, string containmentFailureMessage) + { + string normalizedRoot = NormalizeFullPath(root); + string candidatePath = ResolvePath(normalizedRoot, path); + if (!IsPathInDirectory(candidatePath, normalizedRoot)) + { + throw new InvalidDataException(containmentFailureMessage); + } + + return candidatePath; + } + + /// + /// Determines whether a path is a directory or one of its children using Windows case semantics. + /// + public static bool IsPathInDirectory(string path, string directory) + { + string normalizedDirectory = NormalizeFullPath(directory); + string normalizedPath = NormalizeFullPath(path); + return IsNormalizedPathInDirectory(normalizedPath, normalizedDirectory); + } + + /// + /// Determines whether a path is strictly below a directory using Windows case semantics. + /// + internal static bool IsPathBelowDirectory(string path, string directory) + { + string normalizedDirectory = NormalizeFullPath(directory); + string normalizedPath = NormalizeFullPath(path); + return !AreNormalizedPathsEquivalent(normalizedPath, normalizedDirectory) && + IsNormalizedPathInDirectory(normalizedPath, normalizedDirectory); + } + + /// + /// Determines whether a relative path identifies an entry outside its origin. + /// + public static bool RelativePathLeavesRoot(string relativePath) + { + ArgumentNullException.ThrowIfNull(relativePath); + + string normalizedPath = NormalizeRelativePath(relativePath); + return string.Equals(normalizedPath, "..", StringComparison.Ordinal) || + normalizedPath.StartsWith("../", StringComparison.Ordinal) || + Path.IsPathRooted(relativePath); + } + + /// + /// Normalizes one user-supplied Windows path segment and rejects reserved or traversing names. + /// + public 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('.') || + IsReservedDeviceName(normalizedSegment)) + { + throw new ArgumentException("Path segments must not use reserved file-system names.", paramName); + } + + return normalizedSegment; + } + + 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'; + } + + private static bool AreNormalizedPathsEquivalent(string left, string right) + { + return string.Equals(left, right, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsNormalizedPathInDirectory(string path, string directory) + { + if (AreNormalizedPathsEquivalent(path, directory)) + { + return true; + } + + string directoryPrefix = Path.EndsInDirectorySeparator(directory) + ? directory + : directory + Path.DirectorySeparatorChar; + return path.StartsWith(directoryPrefix, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/GenLauncherGO.Core/Integrity/Models/ContentIntegrityIssue.cs b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityIssue.cs new file mode 100644 index 00000000..d4a57d7b --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityIssue.cs @@ -0,0 +1,11 @@ +namespace GenLauncherGO.Core.Integrity.Models; + +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..5314d7b9 --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityReport.cs @@ -0,0 +1,25 @@ +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 +{ + public ContentIntegrityReport(IReadOnlyList issues) + { + ArgumentNullException.ThrowIfNull(issues); + Issues = Array.AsReadOnly(issues.ToArray()); + } + + public IReadOnlyList Issues { get; } + + public bool HasIssues => Issues.Count > 0; + + public bool HasUnknownLegacyIssues => Issues.Any(issue => issue.Action == IntegrityIssueAction.TrustAsManual); + + 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..5a668a6b --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityTarget.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Core.Integrity.Models; + +/// +/// Describes one launcher-owned directory that must be verified. +/// +public sealed record ContentIntegrityTarget +{ + 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 + .Select(LexicalPath.NormalizeRelativePath) + .ToFrozenSet(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Gets the stable identifier used for snapshot persistence. + /// + public string Id { get; } + + public string DisplayName { get; } + + public string RootDirectory { get; } + + public ContentSourceKind SourceKind { get; init; } + + /// + /// Gets known owned paths that belong to inactive content and must be preserved without verification. + /// + public IReadOnlySet IgnoredRelativePaths { get; } +} diff --git a/GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs b/GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs new file mode 100644 index 00000000..70e5aa1c --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs @@ -0,0 +1,41 @@ +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 +} + +/// +/// Defines shared classifications for launcher content sources. +/// +public static class ContentSourceKindExtensions +{ + /// + /// Determines whether the launcher can restore the content from a managed remote source. + /// + public static bool IsManagedRemote(this ContentSourceKind sourceKind) + { + return sourceKind is ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile; + } +} diff --git a/GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs new file mode 100644 index 00000000..75180ae7 --- /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..4ed8ceb3 --- /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/IGameExecutableDiscoveryService.cs b/GenLauncherGO.Core/Launching/Contracts/IGameExecutableDiscoveryService.cs new file mode 100644 index 00000000..eef5f28e --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/IGameExecutableDiscoveryService.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Launching.Models; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Discovers game and World Builder executables available to the current launcher session. +/// +public interface IGameExecutableDiscoveryService +{ + /// + /// Gets the built-in game client executables for the active game installation. + /// + IReadOnlyList GetGameClients(); + + /// + /// Gets the built-in World Builder executables for the active game installation. + /// + IReadOnlyList GetWorldBuilders(); + + /// + /// Determines whether a root-level executable file name is currently available and safe to launch. + /// + bool IsExecutableAvailable(string? executableName); +} diff --git a/GenLauncherGO.Core/Launching/Contracts/IGameProcessLaunchOperation.cs b/GenLauncherGO.Core/Launching/Contracts/IGameProcessLaunchOperation.cs new file mode 100644 index 00000000..dd1d1fa8 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/IGameProcessLaunchOperation.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading.Tasks; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Represents a launched game or tool process family that can be observed and force closed. +/// +public interface IGameProcessLaunchOperation +{ + /// + /// Gets the executable name for the currently running tracked process. + /// + string CurrentExecutableName { get; } + + /// + /// Gets the task that completes when every tracked process in the launched process family has exited. + /// + Task Completion { get; } + + /// + /// Occurs when changes. + /// + event EventHandler? CurrentExecutableNameChanged; + + /// + /// Force closes the tracked process family. + /// + void ForceClose(); +} diff --git a/GenLauncherGO.Core/Launching/Contracts/IGameProcessLauncher.cs b/GenLauncherGO.Core/Launching/Contracts/IGameProcessLauncher.cs new file mode 100644 index 00000000..f1bd24b8 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/IGameProcessLauncher.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Models; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Launches supported game and tool processes for a prepared game directory. +/// +public interface IGameProcessLauncher +{ + /// + /// Starts the requested game or tool process and returns an operation that tracks its process family. + /// + Task StartAsync( + 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..b5bff125 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityResolutionService.cs @@ -0,0 +1,55 @@ +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. + /// + Task VerifyAsync( + LaunchContentIntegrityTargetRequest request, + CancellationToken cancellationToken); + + /// + /// Captures initial snapshots for matching managed remote caches and reports whether any target was initialized. + /// + Task InitializeUntrackedManagedCachesAsync( + LaunchContentIntegrityResolutionRequest request, + CancellationToken cancellationToken); + + /// + /// Applies confirmed launch-integrity resolutions, including snapshots, cleanup, package repair, and cache refresh. + /// + Task ResolveAsync( + LaunchContentIntegrityResolutionRequest request, + IProgress? progress, + CancellationToken cancellationToken); + + /// + /// Marks a manually imported version as manual content and captures its initial package and cache snapshots. + /// + Task RegisterManualImportAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken); + + /// + /// Captures initial snapshots for a newly installed managed remote version. + /// + Task CaptureManagedInstallSnapshotAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken); + + /// + /// Captures a trusted snapshot for a manually managed cached image target. + /// + Task CaptureManualImageSnapshotAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Launching/Contracts/ILaunchPreparationService.cs b/GenLauncherGO.Core/Launching/Contracts/ILaunchPreparationService.cs new file mode 100644 index 00000000..b9b3ff89 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/ILaunchPreparationService.cs @@ -0,0 +1,35 @@ +using System.Threading; +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. + /// + /// when preparation completed successfully. + bool Prepare( + LaunchPreparationRequest request, + CancellationToken cancellationToken); + + /// + /// Cleans launch-time game-directory state after a launched process exits. + /// + /// when cleanup completed successfully. + bool Cleanup( + LauncherPaths paths, + CancellationToken cancellationToken); + + /// + /// Recovers interrupted launch-time game-directory state during launcher startup. + /// + /// when recovery completed successfully. + bool Recover( + LauncherPaths paths, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Launching/LauncherGameArgumentService.cs b/GenLauncherGO.Core/Launching/LauncherGameArgumentService.cs new file mode 100644 index 00000000..c3af2e05 --- /dev/null +++ b/GenLauncherGO.Core/Launching/LauncherGameArgumentService.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.Core.Launching; + +/// +/// Updates game executable command-line arguments controlled by launcher settings. +/// +public static class LauncherGameArgumentService +{ + /// + /// The argument that starts the game in windowed mode. + /// + public const string WindowedArgument = "-win"; + + /// + /// The argument that skips the normal game startup sequence. + /// + public const string QuickStartArgument = "-quickstart"; + + /// + /// The Generals Online launcher override that prevents it from adding windowed mode. + /// + public const string GeneralsOnlineFullscreenArgument = "-fullscreen"; + + /// + /// The Generals Online game argument that disables its community data patch for the current launch. + /// + public const string GeneralsOnlineDisableCommunityDataPatchArgument = "-disableCommunityDataPatch"; + + /// + /// Adds or removes a command-line argument. + /// + public static string SetArgumentEnabled(string? arguments, string argument, bool enabled) + { + return enabled + ? AddArgument(arguments, argument) + : RemoveArgument(arguments, argument); + } + + /// + /// Determines whether the argument string contains a standalone command-line argument. + /// + public static bool ContainsArgument(string? arguments, string argument) + { + EnsureArgument(argument); + + return EnumerateTokens(arguments) + .Any(token => string.Equals(token.Value, argument, StringComparison.OrdinalIgnoreCase)); + } + + private static string AddArgument(string? arguments, string argument) + { + if (ContainsArgument(arguments, argument)) + { + return arguments ?? string.Empty; + } + + if (string.IsNullOrWhiteSpace(arguments)) + { + return argument; + } + + return $"{arguments.Trim()} {argument}"; + } + + private static string RemoveArgument(string? arguments, string argument) + { + EnsureArgument(argument); + + if (string.IsNullOrWhiteSpace(arguments)) + { + return string.Empty; + } + + return string.Join( + ' ', + EnumerateTokens(arguments) + .Where(token => !string.Equals(token.Value, argument, StringComparison.OrdinalIgnoreCase)) + .Select(token => token.Raw)); + } + + private static void EnsureArgument(string argument) + { + ArgumentException.ThrowIfNullOrWhiteSpace(argument); + } + + /// + /// Enumerates whitespace-delimited command-line tokens while preserving quoted token text. + /// + private static IEnumerable<(string Raw, string Value)> EnumerateTokens(string? arguments) + { + if (string.IsNullOrWhiteSpace(arguments)) + { + yield break; + } + + int index = 0; + while (index < arguments.Length) + { + while (index < arguments.Length && char.IsWhiteSpace(arguments[index])) + { + index++; + } + + if (index >= arguments.Length) + { + yield break; + } + + int start = index; + bool isQuoted = arguments[index] == '"'; + bool inQuotes = isQuoted; + if (isQuoted) + { + index++; + } + + while (index < arguments.Length) + { + char current = arguments[index]; + if (current == '"') + { + inQuotes = !inQuotes; + index++; + continue; + } + + if (!inQuotes && char.IsWhiteSpace(current)) + { + break; + } + + index++; + } + + int end = index; + string raw = arguments[start..end]; + string value = GetComparableTokenValue(raw, isQuoted); + yield return (raw, value); + } + } + + private static string GetComparableTokenValue(string raw, bool isQuoted) + { + return isQuoted && raw.Length >= 2 && raw[^1] == '"' + ? raw[1..^1] + : raw; + } +} diff --git a/GenLauncherGO.Core/Launching/Models/BuiltInExecutable.cs b/GenLauncherGO.Core/Launching/Models/BuiltInExecutable.cs new file mode 100644 index 00000000..8b77f7b6 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/BuiltInExecutable.cs @@ -0,0 +1,45 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes one built-in game or World Builder executable discovered in the active game directory. +/// +public sealed class BuiltInExecutable +{ + public BuiltInExecutable(string executableName, bool isAvailable) + { + ExecutableName = LauncherFileSystemLayout.NormalizeExecutableFileName(executableName); + Kind = ResolveKind(ExecutableName); + IsAvailable = isAvailable; + } + + public string ExecutableName { get; } + + public BuiltInExecutableKind Kind { get; } + + public bool IsAvailable { get; } + + private static BuiltInExecutableKind ResolveKind(string executableName) + { + if (string.Equals( + executableName, + LauncherFileSystemLayout.GeneralsOnlineExecutableFileName, + StringComparison.OrdinalIgnoreCase)) + { + return BuiltInExecutableKind.GeneralsOnline; + } + + return string.Equals( + executableName, + LauncherFileSystemLayout.RetailGameExecutableFileName, + StringComparison.OrdinalIgnoreCase) || + string.Equals( + executableName, + LauncherFileSystemLayout.RetailWorldBuilderExecutableFileName, + StringComparison.OrdinalIgnoreCase) + ? BuiltInExecutableKind.Retail + : BuiltInExecutableKind.Community; + } +} diff --git a/GenLauncherGO.Core/Launching/Models/BuiltInExecutableKind.cs b/GenLauncherGO.Core/Launching/Models/BuiltInExecutableKind.cs new file mode 100644 index 00000000..1f0c104d --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/BuiltInExecutableKind.cs @@ -0,0 +1,13 @@ +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Identifies the launcher-specific behavior of a built-in executable. +/// +public enum BuiltInExecutableKind +{ + Community, + + Retail, + + GeneralsOnline +} diff --git a/GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs b/GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs new file mode 100644 index 00000000..a2305f7a --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs @@ -0,0 +1,57 @@ +using System; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes a game or World Builder process launch request. +/// +public sealed record GameLaunchRequest +{ + private GameLaunchRequest( + GameLaunchTargetKind targetKind, + string gameDirectory, + string executableName, + string? arguments) + { + ArgumentException.ThrowIfNullOrWhiteSpace(gameDirectory); + + TargetKind = targetKind; + GameDirectory = LexicalPath.NormalizeFullPath(gameDirectory); + ExecutableName = LauncherFileSystemLayout.NormalizeExecutableFileName(executableName); + Arguments = arguments ?? string.Empty; + } + + public GameLaunchTargetKind TargetKind { get; } + + public string GameDirectory { get; } + + public string ExecutableName { get; } + + public string Arguments { get; } + + public static GameLaunchRequest ForGameClient( + string gameDirectory, + string executableName, + string? arguments) + { + return new GameLaunchRequest( + GameLaunchTargetKind.GameClient, + gameDirectory, + executableName, + arguments); + } + + public static GameLaunchRequest ForWorldBuilder( + string gameDirectory, + string executableName, + string? arguments) + { + return new GameLaunchRequest( + GameLaunchTargetKind.WorldBuilder, + gameDirectory, + executableName, + arguments); + } +} diff --git a/GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs b/GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs new file mode 100644 index 00000000..5474695b --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs @@ -0,0 +1,8 @@ +namespace GenLauncherGO.Core.Launching.Models; + +public enum GameLaunchTargetKind +{ + GameClient, + + WorldBuilder +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs new file mode 100644 index 00000000..b9fcb076 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs @@ -0,0 +1,40 @@ +using System; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Reports package progress or completion for one launch-integrity resolution target. +/// +public sealed record LaunchContentIntegrityResolutionProgress +{ + private LaunchContentIntegrityResolutionProgress( + string targetId, + PackageUpdateProgress? packageProgress) + { + ArgumentException.ThrowIfNullOrWhiteSpace(targetId); + + TargetId = targetId; + PackageProgress = packageProgress; + } + + public string TargetId { get; } + + public PackageUpdateProgress? PackageProgress { get; } + + public bool Completed => PackageProgress is null; + + public static LaunchContentIntegrityResolutionProgress Package( + string targetId, + PackageUpdateProgress packageProgress) + { + ArgumentNullException.ThrowIfNull(packageProgress); + + return new LaunchContentIntegrityResolutionProgress(targetId, packageProgress); + } + + public static LaunchContentIntegrityResolutionProgress Complete(string targetId) + { + return new LaunchContentIntegrityResolutionProgress(targetId, null); + } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs new file mode 100644 index 00000000..5fdbe802 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +public sealed record LaunchContentIntegrityResolutionRequest +{ + public LaunchContentIntegrityResolutionRequest( + LauncherPaths paths, + ContentIntegrityReport report, + IReadOnlyList targetContexts) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(targetContexts); + + Paths = paths; + Report = report; + TargetContexts = targetContexts.ToArray(); + } + + public LauncherPaths Paths { get; } + + public ContentIntegrityReport Report { get; } + + public IReadOnlyList TargetContexts { get; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs new file mode 100644 index 00000000..b492b79c --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs @@ -0,0 +1,27 @@ +using System; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Launching.Models; + +public sealed record LaunchContentIntegrityTargetContext +{ + public LaunchContentIntegrityTargetContext( + ContentIntegrityTarget target, + LauncherContentVersion version, + bool isCache) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(version); + + Target = target; + Version = version; + IsCache = isCache; + } + + public ContentIntegrityTarget Target { get; } + + public LauncherContentVersion Version { get; } + + public bool IsCache { get; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs new file mode 100644 index 00000000..d5c30449 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +public sealed record LaunchContentIntegrityTargetRequest +{ + public LaunchContentIntegrityTargetRequest( + LauncherPaths paths, + IReadOnlyList activeVersions, + IReadOnlyList allVersions, + string cacheDisplayNameSuffix) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(activeVersions); + ArgumentNullException.ThrowIfNull(allVersions); + ArgumentException.ThrowIfNullOrWhiteSpace(cacheDisplayNameSuffix); + + Paths = paths; + ActiveVersions = activeVersions.ToArray(); + AllVersions = allVersions.ToArray(); + CacheDisplayNameSuffix = cacheDisplayNameSuffix; + } + + public LauncherPaths Paths { get; } + + public IReadOnlyList ActiveVersions { get; } + + public IReadOnlyList AllVersions { get; } + + public string CacheDisplayNameSuffix { get; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs new file mode 100644 index 00000000..0783eeb6 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Launching.Models; + +public sealed record LaunchContentIntegrityVerificationResult +{ + public LaunchContentIntegrityVerificationResult( + ContentIntegrityReport report, + IReadOnlyList targetContexts) + { + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(targetContexts); + + Report = report; + TargetContexts = targetContexts.ToArray(); + } + + public ContentIntegrityReport Report { get; } + + public IReadOnlyList TargetContexts { get; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs new file mode 100644 index 00000000..9fb7c39a --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +public sealed record LaunchContentIntegrityVersionRequest +{ + public LaunchContentIntegrityVersionRequest( + LauncherPaths paths, + LauncherContentVersion version, + IReadOnlyList allVersions, + string cacheDisplayNameSuffix) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(version); + ArgumentNullException.ThrowIfNull(allVersions); + ArgumentException.ThrowIfNullOrWhiteSpace(cacheDisplayNameSuffix); + + Paths = paths; + Version = version; + AllVersions = allVersions.ToArray(); + CacheDisplayNameSuffix = cacheDisplayNameSuffix; + } + + public LauncherPaths Paths { get; } + + public LauncherContentVersion Version { get; } + + public IReadOnlyList AllVersions { get; } + + public string CacheDisplayNameSuffix { get; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs new file mode 100644 index 00000000..8e9cad4f --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Carries selected installed content and the base-script deployment policy from UI to Infrastructure. +/// +public sealed record LaunchPreparationRequest +{ + public LaunchPreparationRequest( + LauncherPaths paths, + IReadOnlyList versions, + bool disableBaseGameScriptFiles) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(versions); + + Paths = paths; + Versions = versions.ToArray(); + DisableBaseGameScriptFiles = disableBaseGameScriptFiles; + } + + public LauncherPaths Paths { get; } + + public IReadOnlyList Versions { get; } + + public bool DisableBaseGameScriptFiles { get; } +} diff --git a/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalog.cs b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalog.cs new file mode 100644 index 00000000..3e3cce5d --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalog.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Exceptions; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Owns the active launcher content aggregate and coordinates its loading, mutation, and persistence. +/// +public interface ILauncherContentCatalog +{ + /// + /// Gets the active in-memory launcher content aggregate. + /// + LauncherData Data { get; } + + /// + /// Gets the active advertising content, or when none is available. + /// + LauncherContentVersion? Advertising { get; } + + /// + /// Gets modification names advertised by the remote repository, or when unavailable. + /// + IReadOnlyList? RepositoryModificationNames { get; } + + /// + /// Initializes the catalog from local state and, when available, the remote repository. + /// + Task InitDataAsync( + LauncherContentCatalogInitializationRequest request, + CancellationToken cancellationToken); + + /// + /// Reads add-ons and patches that belong to the original game. + /// + Task ReadOriginalGameAddonsAndPatchesAsync(CancellationToken cancellationToken); + + /// + /// Reads one repository modification's normalized metadata without adding it to the active catalog. + /// + Task GetRepositoryModificationMetadataAsync( + string name, + CancellationToken cancellationToken); + + /// + /// Downloads one repository modification, caches its images, and adds it to the active catalog. + /// + Task AddRepositoryModificationAsync( + string name, + CancellationToken cancellationToken); + + /// + /// Reads remote patches and add-ons for a modification. + /// + Task ReadPatchesAndAddonsForModAsync( + LauncherContentKey modificationKey, + CancellationToken cancellationToken); + + /// + /// Deletes one installed version and reconciles local state while retaining available catalog metadata. + /// + void UninstallVersion(LauncherContentKey contentKey); + + /// + /// Deletes one installed version, discards that version's catalog metadata, and reconciles local state. + /// + void DiscardVersion(LauncherContentKey contentKey); + + /// + /// Deletes all installed files for a content card, discards the whole card, and reconciles local state. + /// + void DiscardContent(LauncherContentKey contentKey); + + /// + /// Refreshes the catalog from locally installed content and removes stale local-only cards. + /// + void UpdateLocalModificationsData(); + + /// + /// Saves the current catalog selection and installed state. + /// + /// + /// Thrown when the current state cannot be persisted. The in-memory catalog remains authoritative so callers can + /// retry without rolling back completed file-system work. + /// + void SaveLauncherData(); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/IManualModificationImporter.cs b/GenLauncherGO.Core/Mods/Contracts/IManualModificationImporter.cs new file mode 100644 index 00000000..20df2429 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/IManualModificationImporter.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +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 source files into the explicitly owned destination directory. + /// + void Import( + IReadOnlyList sourceFilePaths, + OwnedContentPath destinationPath, + 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..ffc8f7c1 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/IModificationImageFileService.cs @@ -0,0 +1,49 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Provides launcher modification image cache file operations. +/// +/// +/// The modification type participates in cache identity because advertising names retain legacy filesystem +/// normalization. +/// +public interface IModificationImageFileService +{ + /// + /// Finds an existing cached modification image with any extension. + /// + string? FindExistingImageFilePath( + ModificationType modificationType, + string modificationName, + string imageBaseName); + + /// + /// Counts cached image files for a modification. + /// + int CountImageFiles(ModificationType modificationType, string modificationName); + + /// + /// Determines whether a path inside the active launcher-owned image cache points to an existing file. + /// + bool ImageExists(string? imageFilePath); + + /// + /// Removes cached images for a logical image identity and reports whether no matching image remains. + /// + /// The implementation resolves the logical identity inside the active image cache ownership boundary. + bool TryDeleteImage( + ModificationType modificationType, + string modificationName, + string imageBaseName); + + /// + /// Replaces cached images for a modification image base name with a selected source image. + /// + Task ReplaceImageAsync( + ModificationImageReplacementRequest request, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/IModificationThemeCache.cs b/GenLauncherGO.Core/Mods/Contracts/IModificationThemeCache.cs new file mode 100644 index 00000000..2747fdff --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/IModificationThemeCache.cs @@ -0,0 +1,24 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Remembers the palette a modification published, so a themed launcher survives an offline restart. +/// +/// +/// This is a cache of remote manifest data, never an authority: whenever the catalog reaches the backend the +/// published palette wins, and a missing or unreadable entry simply means the launcher wears the active game's +/// palette instead. Entries live beside the modification's cached artwork and are removed with it. +/// +public interface IModificationThemeCache +{ + /// + /// Stores the palette published for one content version, replacing any previously cached entry. + /// + void Save(LauncherContentKey contentKey, LauncherContentTheme theme); + + /// + /// Loads the palette cached for one content version, or when none is available. + /// + LauncherContentTheme? Load(LauncherContentKey contentKey); +} diff --git a/GenLauncherGO.Core/Mods/Exceptions/LauncherContentPersistenceException.cs b/GenLauncherGO.Core/Mods/Exceptions/LauncherContentPersistenceException.cs new file mode 100644 index 00000000..913e1606 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Exceptions/LauncherContentPersistenceException.cs @@ -0,0 +1,12 @@ +using System; + +namespace GenLauncherGO.Core.Mods.Exceptions; + +public sealed class LauncherContentPersistenceException : Exception +{ + public LauncherContentPersistenceException(Exception innerException) + : base("Launcher content state could not be persisted.", innerException) + { + ArgumentNullException.ThrowIfNull(innerException); + } +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContent.cs b/GenLauncherGO.Core/Mods/Models/LauncherContent.cs new file mode 100644 index 00000000..0852d862 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContent.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Owns one launcher content card, its versions, and the single policy for merging catalog and local state. +/// +public sealed class LauncherContent +{ + private readonly List _versions = []; + + public LauncherContent(LauncherContentVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + ContentKey = version.ContentKey.WithoutVersion(); + Versions = _versions.AsReadOnly(); + AddOrMergeVersion(version); + } + + public LauncherContentKey ContentKey { get; } + + public IReadOnlyList Versions { get; } + + /// + /// Gets the latest known version, which is the card's presentation metadata authority. + /// + public LauncherContentVersion LatestVersion => + _versions.OrderBy(version => version).Last(); + + /// + /// Gets the latest installed version, or when no version is installed. + /// + public LauncherContentVersion? LatestInstalledVersion => + _versions + .Where(version => version.Installation.Installed) + .OrderBy(version => version) + .LastOrDefault(); + + public ModificationType ModificationType => ContentKey.ContentType; + + public string Name => ContentKey.Name; + + public bool Installed => _versions.Any(version => version.Installation.Installed); + + public bool IsSelected { get; set; } + + public int NumberInList { get; set; } + + /// + /// Gets the persisted installed selection, falling back to the earliest installed or known version. + /// + /// + /// The fallback preserves the launcher's legacy behavior when saved selection state is missing. + /// + public LauncherContentVersion? GetSelectedVersion() + { + return _versions.FirstOrDefault(version => + version.Installation.Installed && + version.Installation.IsSelected) ?? + _versions + .Where(version => version.Installation.Installed) + .OrderBy(version => version) + .FirstOrDefault() ?? + _versions + .OrderBy(version => version) + .FirstOrDefault(); + } + + /// + /// Adds a new version or merges metadata and local installation state into the matching version. + /// + internal void AddOrMergeVersion(LauncherContentVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + if (version.ContentKey.WithoutVersion() != ContentKey) + { + throw new ArgumentException( + "A launcher content version cannot be merged into a different content card.", + nameof(version)); + } + + int versionIndex = _versions.FindIndex(candidate => + candidate.ContentKey == version.ContentKey); + if (versionIndex < 0) + { + _versions.Add(version); + } + else + { + _versions[versionIndex] = MergeVersion(_versions[versionIndex], version); + } + + IsSelected |= version.Installation.IsSelected; + } + + /// + /// Removes the matching version from this content card. + /// + internal void RemoveVersion(LauncherContentKey contentKey) + { + int versionIndex = _versions.FindIndex(candidate => + candidate.ContentKey == contentKey); + if (versionIndex >= 0) + { + _versions.RemoveAt(versionIndex); + } + } + + /// + /// Applies the legacy-compatible catalog merge precedence once while retaining one shared local-state object. + /// + private static LauncherContentVersion MergeVersion( + LauncherContentVersion existing, + LauncherContentVersion incoming) + { + LauncherContentInstallation installation = existing.Installation; + installation.Installed |= incoming.Installation.Installed; + installation.IsSelected |= incoming.Installation.IsSelected; + if (installation.ContentSourceKind == ContentSourceKind.UnknownLegacy && + incoming.Installation.ContentSourceKind != ContentSourceKind.UnknownLegacy) + { + installation.ContentSourceKind = incoming.Installation.ContentSourceKind; + } + + var merged = new LauncherContentVersion(installation) + { + ModificationType = existing.ModificationType, + Name = existing.Name, + Version = existing.Version, + SimpleDownloadLink = FirstNonEmpty(existing.SimpleDownloadLink, incoming.SimpleDownloadLink), + UIImageSourceLink = FirstNonEmpty(existing.UIImageSourceLink, incoming.UIImageSourceLink), + DiscordLink = FirstNonEmpty(existing.DiscordLink, incoming.DiscordLink), + ModDBLink = FirstNonEmpty(existing.ModDBLink, incoming.ModDBLink), + NewsLink = FirstNonEmpty(existing.NewsLink, incoming.NewsLink), + ParentContentName = existing.ParentContentName, + S3HostLink = FirstNonEmpty(existing.S3HostLink, incoming.S3HostLink), + S3BucketName = FirstNonEmpty(existing.S3BucketName, incoming.S3BucketName), + S3FolderName = FirstNonEmpty(existing.S3FolderName, incoming.S3FolderName), + S3HostPublicKey = FirstNonEmpty(existing.S3HostPublicKey, incoming.S3HostPublicKey), + S3HostSecretKey = FirstNonEmpty(existing.S3HostSecretKey, incoming.S3HostSecretKey), + NetworkInfo = FirstNonEmpty(existing.NetworkInfo, incoming.NetworkInfo), + Deprecated = incoming.Deprecated, + SupportLink = FirstNonEmpty(existing.SupportLink, incoming.SupportLink), + Theme = incoming.Theme ?? existing.Theme + }; + + installation.ContentSourceKind = merged.EffectiveContentSourceKind; + return merged; + } + + private static string FirstNonEmpty(string existing, string incoming) + { + return !string.IsNullOrEmpty(existing) ? existing : incoming; + } +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs new file mode 100644 index 00000000..7e92a65e --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs @@ -0,0 +1,11 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Initializes one game catalog; a missing remote manifest URI selects local-only mode. +/// +public sealed record LauncherContentCatalogInitializationRequest( + Uri? RemoteManifestUri, + LauncherPaths Paths); diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentFileTypes.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentFileTypes.cs new file mode 100644 index 00000000..62a631b8 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentFileTypes.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Names the file formats the launcher accepts as content. +/// +/// +/// Extraction, the artwork cache, package download naming, and the pickers a user chooses files with all branch +/// on these sets, and they agree only because they read them from here. A format offered by a picker but absent +/// from the extractor hands the user a file the launcher then refuses to unpack, and the mismatch is invisible +/// until someone tries it. +/// +public static class LauncherContentFileTypes +{ + /// + /// The extension a game package uses while it is downloaded and deployed. + /// + public const string BigExtension = ".big"; + + /// + /// The extension a game package is stored under once installed, keeping it inert for the game. + /// + public const string GibExtension = ".gib"; + + /// + /// The extension artwork falls back to when a remote image URL declares none the launcher accepts. + /// + public const string DefaultImageExtension = ".png"; + + /// + /// Gets the archive formats the launcher unpacks after a download or a manual import. + /// + public static IReadOnlyList ArchiveExtensions { get; } = + Array.AsReadOnly([".zip", ".rar", ".7z"]); + + /// + /// Gets the game package files a user can import directly, without extraction. + /// + public static IReadOnlyList GamePackageExtensions { get; } = + Array.AsReadOnly([BigExtension, GibExtension]); + + /// + /// Gets the image formats accepted for modification artwork. + /// + public static IReadOnlyList ImageExtensions { get; } = + Array.AsReadOnly([DefaultImageExtension, ".jpg", ".jpeg"]); + + /// + /// Determines whether a path names an archive the launcher unpacks rather than copies. + /// + public static bool IsArchive(string filePath) + { + return HasExtension(ArchiveExtensions, Path.GetExtension(filePath)); + } + + /// + /// Determines whether an extension names an artwork format the launcher caches as published. + /// + public static bool IsImage(string extension) + { + return HasExtension(ImageExtensions, extension); + } + + private static bool HasExtension(IReadOnlyList extensions, string extension) + { + foreach (string candidate in extensions) + { + if (string.Equals(candidate, extension, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentInstallation.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentInstallation.cs new file mode 100644 index 00000000..14870543 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentInstallation.cs @@ -0,0 +1,38 @@ +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Stores mutable local state for one launcher content version. +/// +/// +/// Remote metadata is immutable on . Installation discovery, selection, and +/// integrity trust decisions mutate this separate state and are the only values persisted locally for a version. +/// +public sealed class LauncherContentInstallation +{ + public bool Installed { get; set; } + + public bool IsSelected { get; set; } + + /// + /// Gets or sets whether a download for this version stopped with its partial content deliberately kept. + /// + /// + /// Set when the launcher closes while a download is in flight, so the next session offers to resume instead of + /// starting over. An explicit cancel clears the partial content and leaves this false. + /// + public bool DownloadSuspended { get; set; } + + /// + /// Gets or sets the progress the suspended download had reached, as a percentage. + /// + /// + /// This restores the progress bar's position for the user. It is a display value only: the byte offset a + /// resumed transfer actually continues from is derived from the partial content on disk, so a stale value here + /// can never cause the wrong range to be requested. + /// + public double SuspendedProgressPercentage { get; set; } + + public ContentSourceKind ContentSourceKind { get; set; } = ContentSourceKind.UnknownLegacy; +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentKey.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentKey.cs new file mode 100644 index 00000000..7f50b2ec --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentKey.cs @@ -0,0 +1,124 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Identifies launcher content by type, parent identity, name, and version. +/// +/// +/// Identity text retains its supplied representation and is compared using ordinal, case-insensitive semantics. +/// Missing text is equivalent to an empty string. The original-game key is a stable nonlocalized relationship +/// identity and must not be used as user-visible display text. +/// +public readonly struct LauncherContentKey : IEquatable +{ + public LauncherContentKey( + ModificationType contentType, + string? parentIdentity, + string? name, + string? version) + { + ContentType = contentType; + ParentIdentity = parentIdentity; + Name = name; + Version = version; + } + + public static LauncherContentKey OriginalGame { get; } = + new(ModificationType.Mod, string.Empty, "Original Game", string.Empty); + + /// + /// Creates the name-only identity used by the top-level modification catalog. + /// + public static LauncherContentKey ForModificationName(string? name) + { + return new LauncherContentKey(ModificationType.Mod, string.Empty, name, string.Empty); + } + + public ModificationType ContentType { get; } + + [AllowNull] + public string ParentIdentity => field ?? string.Empty; + + [AllowNull] + public string Name => field ?? string.Empty; + + [AllowNull] + public string Version => field ?? string.Empty; + + /// + /// Gets the card identity for this content, omitting its version. + /// + internal LauncherContentKey WithoutVersion() + { + return new LauncherContentKey(ContentType, ParentIdentity, Name, string.Empty); + } + + /// + /// Determines whether this content belongs to the supplied parent. + /// + public bool IsChildOf(LauncherContentKey parent) + { + return IdentityTextEquals(ParentIdentity, parent.Name); + } + + /// + /// Determines whether this key has the supplied content name. + /// + public bool HasName(string? name) + { + return IdentityTextEquals(Name, name); + } + + /// + /// Formats the legacy-compatible lowercase identity used by launcher-owned integrity records. + /// + public string ToStableString() + { + return string.Join( + ":", + ContentType, + ParentIdentity, + Name, + Version).ToLowerInvariant(); + } + + public bool Equals(LauncherContentKey other) + { + return ContentType == other.ContentType && + IdentityTextEquals(ParentIdentity, other.ParentIdentity) && + IdentityTextEquals(Name, other.Name) && + IdentityTextEquals(Version, other.Version); + } + + public override bool Equals(object? obj) + { + return obj is LauncherContentKey other && Equals(other); + } + + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(ContentType); + hashCode.Add(ParentIdentity, StringComparer.OrdinalIgnoreCase); + hashCode.Add(Name, StringComparer.OrdinalIgnoreCase); + hashCode.Add(Version, StringComparer.OrdinalIgnoreCase); + return hashCode.ToHashCode(); + } + + public static bool operator ==(LauncherContentKey left, LauncherContentKey right) + { + return left.Equals(right); + } + + public static bool operator !=(LauncherContentKey left, LauncherContentKey right) + { + return !left.Equals(right); + } + + private static bool IdentityTextEquals(string? left, string? right) + { + return StringComparer.OrdinalIgnoreCase.Equals(left ?? string.Empty, right ?? string.Empty); + } +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentTheme.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentTheme.cs new file mode 100644 index 00000000..f8dbe4e9 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentTheme.cs @@ -0,0 +1,93 @@ +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes the palette a published modification asks the launcher to wear while it is selected. +/// +/// +/// Property names match the remote manifest's ColorsInformation keys, which are also the launcher's own +/// resource key names. Values stay as the raw strings the backend published: they are colour literals in a +/// presentation format this layer deliberately knows nothing about, so parsing and per-slot fallback happen in the +/// UI where the palette is actually built. Any slot may be absent, and a modification may supply none at all. +/// +public sealed class LauncherContentTheme +{ + public string GenLauncherBorderColor { get; init; } = string.Empty; + + public string GenLauncherInactiveBorder { get; init; } = string.Empty; + + public string GenLauncherInactiveBorder2 { get; init; } = string.Empty; + + public string GenLauncherActiveColor { get; init; } = string.Empty; + + public string GenLauncherDarkFillColor { get; init; } = string.Empty; + + public string GenLauncherDarkBackGround { get; init; } = string.Empty; + + public string GenLauncherLightBackGround { get; init; } = string.Empty; + + public string GenLauncherDefaultTextColor { get; init; } = string.Empty; + + public string GenLauncherDownloadTextColor { get; init; } = string.Empty; + + /// + /// Gets the colour the list-selection gradient starts from. + /// + /// + /// The remote name is ...Color1. Upstream swapped this with ...Color2 on the manifest path only, + /// so a published mod's documented start colour actually rendered at the far stop. This launcher maps the + /// names as documented instead, which is the behaviour mod authors were writing against. + /// + public string GenLauncherListBoxSelectionColor1 { get; init; } = string.Empty; + + public string GenLauncherListBoxSelectionColor2 { get; init; } = string.Empty; + + public string GenLauncherButtonSelectionColor { get; init; } = string.Empty; + + /// + /// Gets the absolute URL of the artwork drawn behind the launcher shell. + /// + public string GenLauncherBackgroundImageLink { get; init; } = string.Empty; + + /// + /// Gets a value indicating whether this theme publishes at least one usable palette or artwork value. + /// + public bool HasValues => + !string.IsNullOrWhiteSpace(GenLauncherBorderColor) || + !string.IsNullOrWhiteSpace(GenLauncherInactiveBorder) || + !string.IsNullOrWhiteSpace(GenLauncherInactiveBorder2) || + !string.IsNullOrWhiteSpace(GenLauncherActiveColor) || + !string.IsNullOrWhiteSpace(GenLauncherDarkFillColor) || + !string.IsNullOrWhiteSpace(GenLauncherDarkBackGround) || + !string.IsNullOrWhiteSpace(GenLauncherLightBackGround) || + !string.IsNullOrWhiteSpace(GenLauncherDefaultTextColor) || + !string.IsNullOrWhiteSpace(GenLauncherDownloadTextColor) || + !string.IsNullOrWhiteSpace(GenLauncherListBoxSelectionColor1) || + !string.IsNullOrWhiteSpace(GenLauncherListBoxSelectionColor2) || + !string.IsNullOrWhiteSpace(GenLauncherButtonSelectionColor) || + !string.IsNullOrWhiteSpace(GenLauncherBackgroundImageLink); + + /// + /// Builds the cache file name a version's shell artwork is stored under. + /// + /// + /// This is the single authority for the name. The download cache writes it, the presentation layer reads it, + /// content removal deletes it, and integrity scanning skips it for inactive versions — they only agree because + /// they all call here. The suffix keeps it clear of the tile image, which is cached under the bare version. + /// + public static string ResolveBackgroundImageBaseName(string version) + { + return (version ?? string.Empty) + "-background"; + } + + /// + /// Builds the cache file name a version's palette is stored under, so it survives an offline restart. + /// + /// + /// The palette is cached beside the artwork it belongs to, which gives both the same lifetime: removing a + /// modification removes its cache folder and everything in it. + /// + public static string ResolveCacheBaseName(string version) + { + return (version ?? string.Empty) + "-theme"; + } +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentVersion.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentVersion.cs new file mode 100644 index 00000000..3c598971 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentVersion.cs @@ -0,0 +1,125 @@ +using System; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes immutable metadata for one launcher content version and its separate mutable local installation state. +/// +/// +/// Infrastructure maps third-party backend documents into this normalized domain model. Canonical identity is always +/// provided by ; object equality is intentionally not an identity mechanism. +/// +public sealed class LauncherContentVersion : IComparable +{ + public LauncherContentVersion() + : this(new LauncherContentInstallation()) + { + } + + public LauncherContentVersion(LauncherContentInstallation installation) + { + Installation = installation ?? throw new ArgumentNullException(nameof(installation)); + } + + public ModificationType ModificationType { get; init; } + + public string Name { get; init; } = string.Empty; + + public string Version { get; init; } = string.Empty; + + public string SimpleDownloadLink { get; init; } = string.Empty; + + // ReSharper disable once InconsistentNaming + public string UIImageSourceLink { get; init; } = string.Empty; + + public string DiscordLink { get; init; } = string.Empty; + + // ReSharper disable once InconsistentNaming + public string ModDBLink { get; init; } = string.Empty; + + public string NewsLink { get; init; } = string.Empty; + + public string ParentContentName { get; init; } = string.Empty; + + public string S3HostLink { get; init; } = string.Empty; + + public string S3BucketName { get; init; } = string.Empty; + + public string S3FolderName { get; init; } = string.Empty; + + public string S3HostPublicKey { get; init; } = string.Empty; + + public string S3HostSecretKey { get; init; } = string.Empty; + + public string NetworkInfo { get; init; } = string.Empty; + + public bool Deprecated { get; init; } + + public string SupportLink { get; init; } = string.Empty; + + /// + /// Gets the palette this version asks the launcher to wear while it is selected, when it published one. + /// + public LauncherContentTheme? Theme { get; init; } + + public LauncherContentInstallation Installation { get; init; } + + /// + /// Gets the content source kind after applying package metadata precedence. + /// + public ContentSourceKind EffectiveContentSourceKind => + ResolveContentSourceKind( + S3HostLink, + S3BucketName, + S3FolderName, + SimpleDownloadLink, + Installation.ContentSourceKind); + + public string DisplayName => string.Join(" ", new[] { Name, Version } + .Where(value => !string.IsNullOrWhiteSpace(value))); + + /// + /// Gets the canonical identity of this content version. + /// + public LauncherContentKey ContentKey => + new(ModificationType, ParentContentName, Name, Version); + + public int CompareTo(LauncherContentVersion? other) + { + return other is null + ? 1 + : LauncherContentVersionComparer.Instance.Compare(Version, other.Version); + } + + public override string ToString() + { + return Name; + } + + /// + /// Resolves the content source kind from package metadata. + /// + 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/LauncherContentVersionComparer.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentVersionComparer.cs new file mode 100644 index 00000000..9f2306c9 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentVersionComparer.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Compares launcher content version labels without converting their digits to a bounded integer. +/// +/// +/// The remote catalog does not define a semantic-version contract. For compatibility, comparison retains the legacy +/// numeric projection: ASCII digits are compared in encounter order, the shorter projection is right-padded with +/// zeroes, labels without ASCII digits compare below numeric labels, and two labels without digits compare equally. +/// Punctuation and suffix text therefore do not define precedence. This comparer makes that boundary explicit while +/// avoiding integer overflow for arbitrarily long digit sequences. +/// +internal sealed class LauncherContentVersionComparer : IComparer +{ + private LauncherContentVersionComparer() + { + } + + /// + /// Gets the shared launcher content version comparer. + /// + public static LauncherContentVersionComparer Instance { get; } = new(); + + public int Compare(string? x, string? y) + { + string leftDigits = ExtractAsciiDigits(x); + string rightDigits = ExtractAsciiDigits(y); + + if (leftDigits.Length == 0 || rightDigits.Length == 0) + { + return leftDigits.Length.CompareTo(rightDigits.Length); + } + + int projectedLength = Math.Max(leftDigits.Length, rightDigits.Length); + return string.CompareOrdinal( + leftDigits.PadRight(projectedLength, '0'), + rightDigits.PadRight(projectedLength, '0')); + } + + private static string ExtractAsciiDigits(string? value) + { + return value is null + ? string.Empty + : new string(value.Where(IsAsciiDigit).ToArray()); + } + + private static bool IsAsciiDigit(char character) + { + return character is >= '0' and <= '9'; + } +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherData.cs b/GenLauncherGO.Core/Mods/Models/LauncherData.cs new file mode 100644 index 00000000..553cbcdd --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherData.cs @@ -0,0 +1,235 @@ +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 +{ + private readonly List _addons = []; + private readonly List _modifications = []; + private readonly List _patches = []; + + public LauncherData() + { + Addons = _addons.AsReadOnly(); + Modifications = _modifications.AsReadOnly(); + Patches = _patches.AsReadOnly(); + } + + public IReadOnlyList Addons { get; } + + public IReadOnlyList Modifications { get; } + + public IReadOnlyList Patches { get; } + + /// + /// Enumerates every modification, add-on, and patch in catalog order. + /// + public IEnumerable AllContent => _modifications.Concat(_addons).Concat(_patches); + + public LauncherContent? GetSelectedMod() + { + return _modifications.FirstOrDefault(modification => modification.IsSelected); + } + + /// + /// Gets patches associated with the supplied modification, or the original game when none is supplied. + /// + public IReadOnlyList GetPatchesFor(LauncherContent? modification) + { + LauncherContentKey parentKey = modification?.ContentKey ?? LauncherContentKey.OriginalGame; + + return _patches + .Where(patch => patch.ContentKey.IsChildOf(parentKey)) + .ToList(); + } + + /// + /// Gets add-ons associated with the supplied modification or patch. + /// + public IReadOnlyList GetAddonsFor( + LauncherContent? modification, + LauncherContent? patch) + { + LauncherContentKey parentKey = modification?.ContentKey ?? LauncherContentKey.OriginalGame; + LauncherContentKey? patchKey = patch?.ContentKey; + + // Direct add-ons stay ahead of patch add-ons regardless of catalog insertion order. + return _addons + .Where(addon => addon.ContentKey.IsChildOf(parentKey)) + .Union(_addons.Where(addon => + patchKey.HasValue && + addon.ContentKey.IsChildOf(patchKey.Value))) + .ToList(); + } + + public IReadOnlyList GetAllModificationVersions() + { + return _modifications + .SelectMany(modification => modification.Versions) + .ToList(); + } + + public LauncherContent? FindContent(LauncherContentKey contentKey) + { + List? contentStorage = GetContentStorage(contentKey.ContentType); + return contentStorage is null + ? null + : FindContentCard(contentStorage, contentKey); + } + + /// + /// Adds a supported content version or merges it into the matching content card. + /// + public void AddOrUpdate(LauncherContentVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + if (version.ModificationType == ModificationType.Addon && + string.IsNullOrEmpty(version.ParentContentName)) + { + return; + } + + List? contentStorage = GetContentStorage(version.ModificationType); + if (contentStorage != null) + { + AddOrUpdateContentVersion(contentStorage, version); + } + } + + /// + /// Deletes a version and removes dependent patch or add-on cards when their parent is removed. + /// + public void DeleteVersion(LauncherContentKey contentKey) + { + List? contentStorage = GetContentStorage(contentKey.ContentType); + if (contentStorage is null) + { + return; + } + + bool removedContentCard = DeleteContentVersion(contentStorage, contentKey); + if (!removedContentCard) + { + return; + } + + DeleteDependentContent(contentKey); + } + + /// + /// Deletes an entire content card and any patch or add-on cards that depend on it. + /// + public void DeleteContent(LauncherContentKey contentKey) + { + List? contentStorage = GetContentStorage(contentKey.ContentType); + if (contentStorage is null) + { + return; + } + + LauncherContent? content = FindContentCard(contentStorage, contentKey); + if (content is null) + { + return; + } + + contentStorage.Remove(content); + DeleteDependentContent(contentKey); + } + + private void DeleteDependentContent(LauncherContentKey contentKey) + { + if (contentKey.ContentType == ModificationType.Mod) + { + var dependentPatches = _patches + .Where(patch => IsDependentOn(patch, contentKey)) + .ToList(); + + foreach (LauncherContent patch in dependentPatches) + { + DeleteDependentAddons(patch.ContentKey); + _patches.Remove(patch); + } + } + + if (contentKey.ContentType is ModificationType.Mod or ModificationType.Patch) + { + DeleteDependentAddons(contentKey); + } + } + + private static void AddOrUpdateContentVersion( + List contentStorage, + LauncherContentVersion version) + { + LauncherContent? savedContent = FindContentCard(contentStorage, version.ContentKey); + + if (savedContent != null) + { + savedContent.AddOrMergeVersion(version); + } + else + { + contentStorage.Add(new LauncherContent(version)); + } + } + + private static bool DeleteContentVersion( + List contentStorage, + LauncherContentKey contentKey) + { + LauncherContent? savedContent = FindContentCard(contentStorage, contentKey); + if (savedContent is null) + { + return false; + } + + savedContent.RemoveVersion(contentKey); + + if (savedContent.Versions.Count == 0) + { + contentStorage.Remove(savedContent); + return true; + } + + return false; + } + + private static LauncherContent? FindContentCard( + List contentStorage, + LauncherContentKey contentKey) + { + LauncherContentKey cardKey = contentKey.WithoutVersion(); + return contentStorage.Find(content => content.ContentKey == cardKey); + } + + private void DeleteDependentAddons(LauncherContentKey parentKey) + { + _addons.RemoveAll(addon => IsDependentOn(addon, parentKey)); + } + + private static bool IsDependentOn( + LauncherContent modification, + LauncherContentKey parentKey) + { + return !string.IsNullOrWhiteSpace(parentKey.Name) && + modification.ContentKey.IsChildOf(parentKey); + } + + private List? GetContentStorage(ModificationType contentType) + { + return contentType switch + { + ModificationType.Mod => _modifications, + ModificationType.Addon => _addons, + ModificationType.Patch => _patches, + _ => null + }; + } +} diff --git a/GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs b/GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs new file mode 100644 index 00000000..ca6cb097 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs @@ -0,0 +1,26 @@ +using System; + +namespace GenLauncherGO.Core.Mods.Models; + +public sealed class ModificationImageReplacementRequest +{ + public ModificationImageReplacementRequest( + string modificationName, + string imageBaseName, + string sourceImagePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modificationName); + ArgumentException.ThrowIfNullOrWhiteSpace(imageBaseName); + ArgumentException.ThrowIfNullOrWhiteSpace(sourceImagePath); + + ModificationName = modificationName; + ImageBaseName = imageBaseName; + SourceImagePath = sourceImagePath; + } + + public string ModificationName { get; } + + public string ImageBaseName { get; } + + public string SourceImagePath { get; } +} diff --git a/GenLauncherGO.Core/Mods/Models/ModificationType.cs b/GenLauncherGO.Core/Mods/Models/ModificationType.cs new file mode 100644 index 00000000..4753eaf3 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ModificationType.cs @@ -0,0 +1,27 @@ +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Identifies a launcher content category. +/// +public enum ModificationType +{ + /// + /// A game modification. + /// + Mod = 0, + + /// + /// An add-on for a game modification or patch. + /// + Addon = 1, + + /// + /// A patch for a game modification or the original game. + /// + Patch = 2, + + /// + /// Advertising content displayed in the launcher. + /// + Advertising = 3 +} diff --git a/GenLauncherGO.Core/Mods/Models/OwnedContentPath.cs b/GenLauncherGO.Core/Mods/Models/OwnedContentPath.cs new file mode 100644 index 00000000..ad10520e --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/OwnedContentPath.cs @@ -0,0 +1,39 @@ +using System; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Identifies one normalized launcher-owned content path together with the root that owns it. +/// +public sealed record OwnedContentPath +{ + /// + /// Initializes a new instance of the record. + /// + /// + /// Thrown when either path is missing or is not below + /// . + /// + public OwnedContentPath(string ownerRoot, string fullPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ownerRoot); + ArgumentException.ThrowIfNullOrWhiteSpace(fullPath); + + string normalizedOwnerRoot = LexicalPath.NormalizeFullPath(ownerRoot); + string normalizedFullPath = LexicalPath.NormalizeFullPath(fullPath); + if (!LexicalPath.IsPathBelowDirectory(normalizedFullPath, normalizedOwnerRoot)) + { + throw new ArgumentException("An owned content path must be below its owning root.", nameof(fullPath)); + } + + OwnerRoot = normalizedOwnerRoot; + FullPath = normalizedFullPath; + } + + public string OwnerRoot { get; } + + public string FullPath { get; } + + public string RelativePath => LexicalPath.GetRelativePath(OwnerRoot, FullPath); +} diff --git a/GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs b/GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs new file mode 100644 index 00000000..20f1d172 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs @@ -0,0 +1,94 @@ +using System; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Mods.Services; + +/// +/// Resolves launcher-owned content paths from canonical content identities. +/// +public static class LauncherContentPathResolver +{ + /// + /// Resolves the owned installed version directory, or returns for an incomplete or + /// unsupported identity. + /// + public static OwnedContentPath? ResolveVersionPath( + LauncherPaths paths, + LauncherContentKey contentKey) + { + ArgumentNullException.ThrowIfNull(paths); + if (string.IsNullOrWhiteSpace(contentKey.Version)) + { + return null; + } + + OwnedContentPath? contentPath = ResolveContentPath(paths, contentKey); + if (contentPath is null) + { + return null; + } + + string safeVersion = LexicalPath.NormalizePathSegment(contentKey.Version, nameof(contentKey.Version)); + string versionPath = LexicalPath.ResolvePath(contentPath.FullPath, safeVersion); + return new OwnedContentPath(contentPath.OwnerRoot, versionPath); + } + + /// + /// Resolves the owned content-card directory, or returns for an incomplete identity. + /// + public static OwnedContentPath? ResolveContentPath( + LauncherPaths paths, + LauncherContentKey contentKey) + { + ArgumentNullException.ThrowIfNull(paths); + string[]? pathSegments = ResolveContentPathSegments(contentKey); + return pathSegments is null + ? null + : ResolvePackagePath(paths.ModsDirectory, pathSegments); + } + + /// + /// Resolves the owned cleanup root, or returns for an incomplete identity. + /// + public static OwnedContentPath? ResolveCleanupRootPath( + LauncherPaths paths, + LauncherContentKey contentKey) + { + ArgumentNullException.ThrowIfNull(paths); + string[]? pathSegments = ResolveContentPathSegments(contentKey); + return pathSegments is null + ? null + : ResolvePackagePath(paths.ModsDirectory, pathSegments[0]); + } + + private static string[]? ResolveContentPathSegments(LauncherContentKey contentKey) + { + if (string.IsNullOrWhiteSpace(contentKey.Name)) + { + return null; + } + + return contentKey.ContentType switch + { + ModificationType.Addon when !string.IsNullOrWhiteSpace(contentKey.ParentIdentity) => + [contentKey.ParentIdentity, LauncherFileSystemLayout.AddonsFolderName, contentKey.Name], + ModificationType.Patch when !string.IsNullOrWhiteSpace(contentKey.ParentIdentity) => + [contentKey.ParentIdentity, LauncherFileSystemLayout.PatchesFolderName, contentKey.Name], + ModificationType.Mod => [contentKey.Name], + _ => null + }; + } + + private static OwnedContentPath ResolvePackagePath(string modsDirectory, params string?[] segments) + { + string[] safeSegments = segments + .Select((segment, index) => LexicalPath.NormalizePathSegment(segment, $"segment{index}")) + .ToArray(); + string fullPath = LexicalPath.ResolvePath(modsDirectory, Path.Combine(safeSegments)); + return new OwnedContentPath(modsDirectory, fullPath); + } +} diff --git a/GenLauncherGO.Core/Properties/AssemblyInfo.cs b/GenLauncherGO.Core/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..63d47e5d --- /dev/null +++ b/GenLauncherGO.Core/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Runtime.CompilerServices; + +// Lets Core types stay internal when only the test suite needs to reach them, so +// visibility reflects production consumption rather than test access. Matches the +// Infrastructure and UI assemblies. +[assembly: InternalsVisibleTo("GenLauncherGO.Tests")] diff --git a/GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs b/GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs new file mode 100644 index 00000000..1e936ca9 --- /dev/null +++ b/GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs @@ -0,0 +1,16 @@ +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. + /// + Task CanConnectAsync(Uri endpointUri, CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs b/GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs new file mode 100644 index 00000000..e99aaab8 --- /dev/null +++ b/GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs @@ -0,0 +1,30 @@ +using System; +using GenLauncherGO.Core.Settings.Exceptions; +using GenLauncherGO.Core.Settings.Models; + +namespace GenLauncherGO.Core.Settings.Contracts; + +/// +/// Provides the current launcher preferences and persists preference updates. +/// +public interface ILauncherPreferencesService +{ + /// + /// Gets the current launcher preferences. + /// + LauncherPreferences Current { get; } + + /// + /// Occurs after launcher preferences have changed. + /// + event EventHandler? PreferencesChanged; + + /// + /// Persists the supplied launcher preferences and publishes the updated state. + /// + /// + /// Thrown when the requested preferences cannot be persisted. In that case, + /// and remain unchanged. + /// + void Update(LauncherPreferences preferences); +} diff --git a/GenLauncherGO.Core/Settings/Exceptions/LauncherPreferencesPersistenceException.cs b/GenLauncherGO.Core/Settings/Exceptions/LauncherPreferencesPersistenceException.cs new file mode 100644 index 00000000..ff59bf01 --- /dev/null +++ b/GenLauncherGO.Core/Settings/Exceptions/LauncherPreferencesPersistenceException.cs @@ -0,0 +1,12 @@ +using System; + +namespace GenLauncherGO.Core.Settings.Exceptions; + +public sealed class LauncherPreferencesPersistenceException : Exception +{ + public LauncherPreferencesPersistenceException(Exception innerException) + : base("Launcher preferences could not be persisted.", innerException) + { + ArgumentNullException.ThrowIfNull(innerException); + } +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherCustomExecutable.cs b/GenLauncherGO.Core/Settings/Models/LauncherCustomExecutable.cs new file mode 100644 index 00000000..080aa912 --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherCustomExecutable.cs @@ -0,0 +1,19 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Settings.Models; + +public sealed record LauncherCustomExecutable +{ + public LauncherCustomExecutable(string displayName, string executableName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + + DisplayName = displayName.Trim(); + ExecutableName = LauncherFileSystemLayout.NormalizeExecutableFileName(executableName); + } + + public string DisplayName { get; } + + public string ExecutableName { get; } +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherGamePreferences.cs b/GenLauncherGO.Core/Settings/Models/LauncherGamePreferences.cs new file mode 100644 index 00000000..e3069e9f --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherGamePreferences.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Launching.Models; + +namespace GenLauncherGO.Core.Settings.Models; + +public sealed record LauncherGamePreferences +{ + public int LaunchesCount { get; init; } + + public string SelectedGameClient { get; init; } = string.Empty; + + public string SelectedWorldBuilder { get; init; } = string.Empty; + + public string GameArguments { get; init; } = string.Empty; + + public string WorldBuilderArguments { get; init; } = string.Empty; + + public double ModsListVerticalOffset { get; init; } + + /// + /// Gets the row the advertising tile occupies in the modification list. The tile is rebuilt from the remote + /// catalog on every start, so unlike a modification it has no persisted card of its own to carry its position. + /// + public int AdvertisingPositionInList { get; init; } + + public IReadOnlyList CustomGameClients { get; init; } = + Array.Empty(); + + public IReadOnlyList CustomWorldBuilders { get; init; } = + Array.Empty(); + + /// + /// Gets the custom executable registrations for one launch target. + /// + public IReadOnlyList GetCustomExecutables(GameLaunchTargetKind targetKind) + { + return targetKind switch + { + GameLaunchTargetKind.GameClient => CustomGameClients, + GameLaunchTargetKind.WorldBuilder => CustomWorldBuilders, + _ => throw new ArgumentOutOfRangeException(nameof(targetKind), targetKind, "Unknown launch target.") + }; + } + + /// + /// Gets the selected executable name for one launch target. + /// + public string GetSelectedExecutable(GameLaunchTargetKind targetKind) + { + return targetKind switch + { + GameLaunchTargetKind.GameClient => SelectedGameClient, + GameLaunchTargetKind.WorldBuilder => SelectedWorldBuilder, + _ => throw new ArgumentOutOfRangeException(nameof(targetKind), targetKind, "Unknown launch target.") + }; + } + + /// + /// Replaces the custom executable registrations for one launch target. + /// + public LauncherGamePreferences WithCustomExecutables( + GameLaunchTargetKind targetKind, + IReadOnlyList executables) + { + ArgumentNullException.ThrowIfNull(executables); + + return targetKind switch + { + GameLaunchTargetKind.GameClient => this with { CustomGameClients = executables }, + GameLaunchTargetKind.WorldBuilder => this with { CustomWorldBuilders = executables }, + _ => throw new ArgumentOutOfRangeException(nameof(targetKind), targetKind, "Unknown launch target.") + }; + } + + /// + /// Replaces the selected executable name for one launch target. + /// + public LauncherGamePreferences WithSelectedExecutable( + GameLaunchTargetKind targetKind, + string executableName) + { + ArgumentNullException.ThrowIfNull(executableName); + + return targetKind switch + { + GameLaunchTargetKind.GameClient => this with { SelectedGameClient = executableName }, + GameLaunchTargetKind.WorldBuilder => this with { SelectedWorldBuilder = executableName }, + _ => throw new ArgumentOutOfRangeException(nameof(targetKind), targetKind, "Unknown launch target.") + }; + } +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherGamePreferencesSet.cs b/GenLauncherGO.Core/Settings/Models/LauncherGamePreferencesSet.cs new file mode 100644 index 00000000..d129698d --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherGamePreferencesSet.cs @@ -0,0 +1,28 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Settings.Models; + +public sealed record LauncherGamePreferencesSet +{ + public LauncherGamePreferences Generals { get; init; } = new(); + + public LauncherGamePreferences ZeroHour { get; init; } = new(); + + public LauncherGamePreferences Get(SupportedGame game) + { + return PerGame.Select(game, Generals, ZeroHour, nameof(game)); + } + + public LauncherGamePreferencesSet With(SupportedGame game, LauncherGamePreferences preferences) + { + ArgumentNullException.ThrowIfNull(preferences); + + return game switch + { + SupportedGame.Generals => this with { Generals = preferences }, + SupportedGame.ZeroHour => this with { ZeroHour = preferences }, + _ => throw PerGame.Unsupported(game, nameof(game)) + }; + } +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherInstallations.cs b/GenLauncherGO.Core/Settings/Models/LauncherInstallations.cs new file mode 100644 index 00000000..6bd7df26 --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherInstallations.cs @@ -0,0 +1,51 @@ +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Settings.Models; + +public sealed record LauncherInstallations +{ + public string? Generals { get; init; } + + public string? ZeroHour { get; init; } + + public string? GetPath(SupportedGame game) + { + return PerGame.Select(game, Generals, ZeroHour, nameof(game)); + } + + public LauncherInstallations WithPath(SupportedGame game, string? path) + { + return game switch + { + SupportedGame.Generals => this with { Generals = path }, + SupportedGame.ZeroHour => this with { ZeroHour = path }, + _ => throw PerGame.Unsupported(game, nameof(game)) + }; + } + + /// + /// Resolves the preferred configured game, falling back when exactly one installation is available. + /// + public SupportedGame? ResolvePreferredGame(SupportedGame? preferredGame) + { + bool hasGenerals = !string.IsNullOrWhiteSpace(Generals); + bool hasZeroHour = !string.IsNullOrWhiteSpace(ZeroHour); + + if (preferredGame == SupportedGame.Generals && hasGenerals) + { + return SupportedGame.Generals; + } + + if (preferredGame == SupportedGame.ZeroHour && hasZeroHour) + { + return SupportedGame.ZeroHour; + } + + if (hasGenerals == hasZeroHour) + { + return null; + } + + return hasGenerals ? SupportedGame.Generals : SupportedGame.ZeroHour; + } +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs b/GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs new file mode 100644 index 00000000..cc09415a --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs @@ -0,0 +1,14 @@ +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Settings.Models; + +public sealed record LauncherPreferences +{ + public LauncherInstallations Installations { get; init; } = new(); + + public SupportedGame? LastSelectedGame { get; init; } + + public LauncherSharedPreferences Shared { get; init; } = new(); + + public LauncherGamePreferencesSet Games { get; init; } = new(); +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherSharedPreferences.cs b/GenLauncherGO.Core/Settings/Models/LauncherSharedPreferences.cs new file mode 100644 index 00000000..be468442 --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherSharedPreferences.cs @@ -0,0 +1,14 @@ +namespace GenLauncherGO.Core.Settings.Models; + +public sealed record LauncherSharedPreferences +{ + public bool AutoDeleteOldVersions { get; init; } + + public bool HideLauncherAfterGameStart { get; init; } + + public bool EnableDiagnosticLogging { get; init; } + + public bool UseEnglishLanguage { get; init; } + + public bool HasShownRetailGenPatcherRecommendation { get; init; } +} diff --git a/GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs b/GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs new file mode 100644 index 00000000..0370e6ee --- /dev/null +++ b/GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs @@ -0,0 +1,20 @@ +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. + /// + void OpenUri(string uri); + + /// + /// Opens a folder with the operating system shell. + /// + void OpenFolder( + string folderPath, + bool requireFiles = false, + bool createIfMissing = false); +} diff --git a/GenLauncherGO.Core/Startup/Contracts/IGameInstallationService.cs b/GenLauncherGO.Core/Startup/Contracts/IGameInstallationService.cs new file mode 100644 index 00000000..61e5549b --- /dev/null +++ b/GenLauncherGO.Core/Startup/Contracts/IGameInstallationService.cs @@ -0,0 +1,96 @@ +using System; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup.Models; + +namespace GenLauncherGO.Core.Startup.Contracts; + +/// +/// Validates user-selected game directories and discovers valid Windows installations. +/// +public interface IGameInstallationService +{ + /// + /// Finds a supported game root at or above the launcher executable directory, or returns + /// for a standalone launcher. + /// + GameInstallationLocation? FindContainingInstallation(string executableDirectory); + + /// + /// Validates that one selected root is an existing safe directory whose relationship to the launcher is allowed + /// and which contains at least one built-in executable for the selected game. + /// + GameInstallationValidationResult Validate( + SupportedGame game, + string? directory, + string executableDirectory); + + /// + /// Retains valid configured paths and fills only missing or invalid paths from trusted registry views. + /// + LauncherInstallations DiscoverValidInstallations( + LauncherInstallations current, + string executableDirectory); +} + +/// +/// Applies installation-set invariants on top of the canonical per-game validation boundary. +/// +public static class GameInstallationServiceExtensions +{ + /// + /// Validates and canonicalizes the complete supported installation set. + /// Empty paths remain optional, but every nonempty path must be valid, at least one installation must remain, + /// and the two games must not resolve to the same physical directory. + /// + public static LauncherInstallationsValidationResult ValidateInstallations( + this IGameInstallationService installationService, + LauncherInstallations installations, + string executableDirectory) + { + ArgumentNullException.ThrowIfNull(installationService); + ArgumentNullException.ThrowIfNull(installations); + ArgumentException.ThrowIfNullOrWhiteSpace(executableDirectory); + + GameInstallationValidationResult generals = installationService.Validate( + SupportedGame.Generals, + installations.Generals, + executableDirectory); + GameInstallationValidationResult zeroHour = installationService.Validate( + SupportedGame.ZeroHour, + installations.ZeroHour, + executableDirectory); + + string? enteredGeneralsPath = installations.Generals?.Trim(); + string? enteredZeroHourPath = installations.ZeroHour?.Trim(); + bool hasGeneralsPath = !string.IsNullOrEmpty(enteredGeneralsPath); + bool hasZeroHourPath = !string.IsNullOrEmpty(enteredZeroHourPath); + bool hasInvalidNonemptyPath = + (hasGeneralsPath && !generals.IsValid) || + (hasZeroHourPath && !zeroHour.IsValid); + bool hasIdenticalEnteredPath = + hasGeneralsPath && + hasZeroHourPath && + LexicalPath.AreEquivalent(enteredGeneralsPath, enteredZeroHourPath); + bool hasDuplicateCanonicalPath = + generals is { IsValid: true, CanonicalPath: not null } && + zeroHour is { IsValid: true, CanonicalPath: not null } && + LexicalPath.AreEquivalent(generals.CanonicalPath, zeroHour.CanonicalPath); + bool hasDuplicatePath = hasIdenticalEnteredPath || hasDuplicateCanonicalPath; + var canonicalInstallations = new LauncherInstallations + { + Generals = generals.IsValid ? generals.CanonicalPath : null, + ZeroHour = zeroHour.IsValid ? zeroHour.CanonicalPath : null + }; + bool isValid = !hasInvalidNonemptyPath && + !hasDuplicatePath && + (generals.IsValid || zeroHour.IsValid); + + return new LauncherInstallationsValidationResult( + generals, + zeroHour, + canonicalInstallations, + hasDuplicatePath, + isValid); + } +} diff --git a/GenLauncherGO.Core/Startup/Contracts/ILauncherHostEnvironmentService.cs b/GenLauncherGO.Core/Startup/Contracts/ILauncherHostEnvironmentService.cs new file mode 100644 index 00000000..ef73f9cd --- /dev/null +++ b/GenLauncherGO.Core/Startup/Contracts/ILauncherHostEnvironmentService.cs @@ -0,0 +1,40 @@ +using System; +using GenLauncherGO.Core.Startup.Models; + +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. + /// + string GetExecutableDirectory(); + + /// + /// Returns whether the current process is running with elevated administrator privileges. + /// + bool IsCurrentProcessElevated(); + + /// + /// Returns whether a directory is under a protected Program Files location. + /// + bool IsProtectedProgramFilesDirectory(string directory); + + /// + /// Attempts to start a replacement instance of the current launcher process. + /// + LauncherRestartResult TryRestartCurrentProcess(); + + /// + /// Attempts to acquire the launcher single-instance guard; the returned guard reports whether startup 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..390c9ad4 --- /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/ILauncherPathResolver.cs b/GenLauncherGO.Core/Startup/ILauncherPathResolver.cs new file mode 100644 index 00000000..2c9a8f3f --- /dev/null +++ b/GenLauncherGO.Core/Startup/ILauncherPathResolver.cs @@ -0,0 +1,22 @@ +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. + /// + LauncherStoragePaths Resolve(string executableDirectory); + + /// + /// Creates the shared launcher-owned directories. + /// + void PrepareLauncherDirectories(LauncherStoragePaths paths); + + /// + /// Creates launcher-owned directories for one supported game and optionally clears its temporary files. + /// + void PrepareGameDirectories(LauncherPaths paths, bool cleanTemporaryDirectory); +} diff --git a/GenLauncherGO.Core/Startup/LauncherFileSystemLayout.cs b/GenLauncherGO.Core/Startup/LauncherFileSystemLayout.cs new file mode 100644 index 00000000..03ec6c9a --- /dev/null +++ b/GenLauncherGO.Core/Startup/LauncherFileSystemLayout.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Core.Startup; + +/// +/// Defines the canonical launcher-owned folder layout and supported game file names. +/// +public static class LauncherFileSystemLayout +{ + public const string LauncherDataFolderName = "GenLauncherGO Data"; + + internal const string GeneralsDataFolderName = "C&C Generals Data"; + + internal const string ZeroHourDataFolderName = "C&C Zero Hour Data"; + + internal const string RuntimeFolderName = "Runtime"; + + internal const string CacheFolderName = "Cache"; + + internal const string ImagesFolderName = "Images"; + + internal const string ModsFolderName = "Mods"; + + internal const string LogsFolderName = "Logs"; + + internal const string TempFolderName = "Temp"; + + internal const string DeploymentFolderName = "Deployment"; + + internal const string IntegrityFolderName = "Integrity"; + + internal const string StateFolderName = "State"; + + internal const string PackageBackupsFolderName = "PackageBackups"; + + public const string PackagesFolderName = "Packages"; + + public const string AddonsFolderName = "Addons"; + + public const string PatchesFolderName = "Patches"; + + public const string ZeroHourCommunityExecutableFileName = "generalszh.exe"; + + public const string GeneralsCommunityExecutableFileName = "generalsv.exe"; + + public const string GeneralsOnlineExecutableFileName = "generalsonlinezh.exe"; + + public const string RetailGameExecutableFileName = "generals.exe"; + + public const string ZeroHourCommunityWorldBuilderExecutableFileName = "worldbuilderzh.exe"; + + public const string GeneralsCommunityWorldBuilderExecutableFileName = "worldbuilderv.exe"; + + public const string RetailWorldBuilderExecutableFileName = "WorldBuilder.exe"; + + private static readonly IReadOnlyList _generalsGameExecutableNames = Array.AsReadOnly( + [ + GeneralsCommunityExecutableFileName, + RetailGameExecutableFileName + ]); + + private static readonly IReadOnlyList _zeroHourGameExecutableNames = Array.AsReadOnly( + [ + GeneralsOnlineExecutableFileName, + ZeroHourCommunityExecutableFileName, + RetailGameExecutableFileName + ]); + + private static readonly IReadOnlyList _generalsWorldBuilderExecutableNames = Array.AsReadOnly( + [ + RetailWorldBuilderExecutableFileName, + GeneralsCommunityWorldBuilderExecutableFileName + ]); + + private static readonly IReadOnlyList _zeroHourWorldBuilderExecutableNames = Array.AsReadOnly( + [ + RetailWorldBuilderExecutableFileName, + ZeroHourCommunityWorldBuilderExecutableFileName + ]); + + /// + /// Gets the built-in game executables accepted for a managed game, in launcher display order. + /// + public static IReadOnlyList GetBuiltInGameExecutableNames(SupportedGame managedGame) + { + return PerGame.Select( + managedGame, + _generalsGameExecutableNames, + _zeroHourGameExecutableNames, + nameof(managedGame)); + } + + /// + /// Gets the built-in World Builder executables accepted for a managed game, in launcher display order. + /// + public static IReadOnlyList GetBuiltInWorldBuilderExecutableNames(SupportedGame managedGame) + { + return PerGame.Select( + managedGame, + _generalsWorldBuilderExecutableNames, + _zeroHourWorldBuilderExecutableNames, + nameof(managedGame)); + } + + /// + /// Normalizes a root-level Windows executable file name. + /// + /// + /// Thrown when the value is not a safe root-level .exe file name. + /// + public static string NormalizeExecutableFileName(string? executableName) + { + string normalizedName = LexicalPath.NormalizePathSegment( + executableName, + nameof(executableName)); + if (!string.Equals(Path.GetExtension(normalizedName), ".exe", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Executable file names must use the .exe extension.", nameof(executableName)); + } + + return normalizedName; + } + + /// + /// Gets the launcher-owned per-title directory name for a supported game. + /// + internal static string GetGameDataFolderName(SupportedGame managedGame) + { + return PerGame.Select( + managedGame, + GeneralsDataFolderName, + ZeroHourDataFolderName, + nameof(managedGame)); + } +} diff --git a/GenLauncherGO.Core/Startup/LauncherPaths.cs b/GenLauncherGO.Core/Startup/LauncherPaths.cs new file mode 100644 index 00000000..c661168e --- /dev/null +++ b/GenLauncherGO.Core/Startup/LauncherPaths.cs @@ -0,0 +1,163 @@ +using System; +using System.IO; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Startup; + +/// +/// Describes one supported game installation and its isolated launcher-owned data directory. +/// +public sealed record LauncherPaths +{ + private const string LauncherDataFileName = "LauncherData.yaml"; + + private const string OwnedPathContainmentFailureMessage = + "The resolved path must stay inside the launcher-owned directory."; + + internal LauncherPaths(SupportedGame game, string gameDirectory, string ownedGameDataDirectory) + { + PerGame.EnsureSupported(game, nameof(game)); + + Game = game; + GameDirectory = LexicalPath.NormalizeFullPath(gameDirectory); + OwnedGameDataDirectory = LexicalPath.NormalizeFullPath(ownedGameDataDirectory); + } + + public SupportedGame Game { get; } + + public string GameDirectory { get; } + + public string OwnedGameDataDirectory { get; } + + public string RuntimeDirectory => Path.Combine(OwnedGameDataDirectory, LauncherFileSystemLayout.RuntimeFolderName); + + public string CacheDirectory => Path.Combine(RuntimeDirectory, LauncherFileSystemLayout.CacheFolderName); + + public string ImagesDirectory => Path.Combine(CacheDirectory, LauncherFileSystemLayout.ImagesFolderName); + + public string ModsDirectory => Path.Combine(OwnedGameDataDirectory, LauncherFileSystemLayout.ModsFolderName); + + public string TempDirectory => Path.Combine(RuntimeDirectory, LauncherFileSystemLayout.TempFolderName); + + public string DeploymentDirectory => Path.Combine(RuntimeDirectory, LauncherFileSystemLayout.DeploymentFolderName); + + public string StateDirectory => Path.Combine(RuntimeDirectory, LauncherFileSystemLayout.StateFolderName); + + public string IntegrityDirectory => Path.Combine(RuntimeDirectory, LauncherFileSystemLayout.IntegrityFolderName); + + public string PackagesDirectory => Path.Combine(TempDirectory, LauncherFileSystemLayout.PackagesFolderName); + + public string PackageBackupsDirectory => + Path.Combine(StateDirectory, LauncherFileSystemLayout.PackageBackupsFolderName); + + public string LauncherDataFilePath => Path.Combine(StateDirectory, LauncherDataFileName); + + /// + /// Builds the image cache directory path for a mod, add-on, patch, or advertisement entry. + /// + /// + /// Thrown when is empty, whitespace, or unsafe for a path segment. + /// + public string GetModificationImagesDirectory(string modificationName) + { + string safeModificationName = LexicalPath.NormalizePathSegment(modificationName, nameof(modificationName)); + + return ResolveOwnedPath(ImagesDirectory, safeModificationName); + } + + /// + /// Builds an image cache file path for a mod, add-on, patch, or advertisement entry. + /// + /// + /// Thrown when or is empty, whitespace, or + /// unsafe for a path segment. + /// + public string GetModificationImageFilePath(string modificationName, string imageFileName) + { + string safeImageFileName = LexicalPath.NormalizePathSegment(imageFileName, nameof(imageFileName)); + + return ResolveOwnedPath(GetModificationImagesDirectory(modificationName), safeImageFileName); + } + + /// + /// Builds a package staging folder path below the launcher temporary directory. + /// + /// + /// Thrown when is empty, whitespace, or cannot be staged below the + /// launcher temporary package directory. + /// + private string GetPackageTemporaryFolderPath(string installedFolderPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(installedFolderPath); + + string installedFullPath = LexicalPath.NormalizeFullPath(installedFolderPath); + string relativePath = LexicalPath.GetRelativePath(ModsDirectory, installedFullPath); + + if (LexicalPath.RelativePathLeavesRoot(relativePath)) + { + relativePath = LexicalPath.NormalizePathSegment( + Path.GetFileName(installedFullPath), + nameof(installedFolderPath)); + } + + string temporaryPackagesRoot = PackagesDirectory; + return ResolveOwnedPath( + temporaryPackagesRoot, + relativePath, + "The installed package path cannot be staged outside the launcher temporary package folder.", + nameof(installedFolderPath)); + } + + /// + /// Builds an owned package staging path for an installed content path. + /// + public OwnedContentPath GetPackageTemporaryPath(OwnedContentPath installedPath) + { + ArgumentNullException.ThrowIfNull(installedPath); + + string temporaryFolderPath = GetPackageTemporaryFolderPath(installedPath.FullPath); + return new OwnedContentPath(PackagesDirectory, temporaryFolderPath); + } + + /// + /// Builds a durable recovery-backup path that mirrors an installed package below the canonical Mods directory. + /// + /// + /// Recovery backups live below State rather than Temp so startup cleanup cannot erase an interrupted replacement. + /// + /// + /// Thrown when is not below the canonical Mods directory. + /// + public OwnedContentPath GetPackageBackupPath(OwnedContentPath installedPath) + { + ArgumentNullException.ThrowIfNull(installedPath); + + if (!LexicalPath.IsPathBelowDirectory(installedPath.FullPath, ModsDirectory)) + { + throw new ArgumentException( + "Package recovery backups can only be created for content below the launcher Mods directory.", + nameof(installedPath)); + } + + string relativePath = LexicalPath.GetRelativePath(ModsDirectory, installedPath.FullPath); + string backupPath = ResolveOwnedPath(PackageBackupsDirectory, relativePath); + return new OwnedContentPath(PackageBackupsDirectory, backupPath); + } + + private static string ResolveOwnedPath( + string rootDirectory, + string relativePath, + string containmentFailureMessage = OwnedPathContainmentFailureMessage, + string? parameterName = null) + { + try + { + return LexicalPath.ResolveContainedPath(rootDirectory, relativePath, containmentFailureMessage); + } + catch (InvalidDataException) + { + throw new ArgumentException(containmentFailureMessage, parameterName); + } + } +} diff --git a/GenLauncherGO.Core/Startup/LauncherRuntimePathContext.cs b/GenLauncherGO.Core/Startup/LauncherRuntimePathContext.cs new file mode 100644 index 00000000..27d77736 --- /dev/null +++ b/GenLauncherGO.Core/Startup/LauncherRuntimePathContext.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Core.Startup; + +/// +/// Owns the active immutable game-path snapshot for a running standalone launcher session. +/// +/// +/// Long-running operations must read once and retain that snapshot for their full +/// lifetime. Replacing the active paths never redirects an operation that is already in progress. +/// +public sealed class LauncherRuntimePathContext +{ + private LauncherPaths _activePaths; + + public LauncherRuntimePathContext( + LauncherStoragePaths storagePaths, + LauncherPaths initialPaths) + { + StoragePaths = storagePaths ?? throw new ArgumentNullException(nameof(storagePaths)); + _activePaths = ValidateOwnedGamePaths(initialPaths); + } + + public LauncherStoragePaths StoragePaths { get; } + + public LauncherPaths ActivePaths => Volatile.Read(ref _activePaths); + + /// + /// Atomically replaces the active game-path snapshot. + /// + public void SwitchActive(LauncherPaths newPaths) + { + Interlocked.Exchange(ref _activePaths, ValidateOwnedGamePaths(newPaths)); + } + + private LauncherPaths ValidateOwnedGamePaths(LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + string expectedDataDirectory = StoragePaths.GetGameDataDirectory(paths.Game); + if (!LexicalPath.AreEquivalent(paths.OwnedGameDataDirectory, expectedDataDirectory)) + { + throw new ArgumentException( + "The active path set must use the standalone launcher's canonical per-game data directory.", + nameof(paths)); + } + + return paths; + } +} diff --git a/GenLauncherGO.Core/Startup/LauncherStoragePaths.cs b/GenLauncherGO.Core/Startup/LauncherStoragePaths.cs new file mode 100644 index 00000000..24597424 --- /dev/null +++ b/GenLauncherGO.Core/Startup/LauncherStoragePaths.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Core.Startup; + +/// +/// Defines the standalone launcher's shared storage root and isolated per-title data roots. +/// +/// +/// The executable directory is chosen by the user. All durable launcher state stays below +/// and never uses a game installation as an ownership boundary. +/// +public sealed record LauncherStoragePaths +{ + private const string PreferencesFileName = "LauncherPreferences.yaml"; + + public LauncherStoragePaths(string executableDirectory) + { + ExecutableDirectory = LexicalPath.NormalizeFullPath(executableDirectory); + } + + public string ExecutableDirectory { get; } + + public string DataDirectory => + Path.Combine(ExecutableDirectory, LauncherFileSystemLayout.LauncherDataFolderName); + + public string LogsDirectory => Path.Combine(DataDirectory, LauncherFileSystemLayout.LogsFolderName); + + public string PreferencesFilePath => Path.Combine(DataDirectory, PreferencesFileName); + + /// + /// Gets the launcher-owned data root isolated to one supported game. + /// + internal string GetGameDataDirectory(SupportedGame game) + { + return Path.Combine(DataDirectory, LauncherFileSystemLayout.GetGameDataFolderName(game)); + } + + /// + /// Creates the sole canonical path set for a validated game installation. + /// + public LauncherPaths CreateGamePaths(SupportedGame game, string validatedGameRoot) + { + ArgumentException.ThrowIfNullOrWhiteSpace(validatedGameRoot); + + return new LauncherPaths(game, validatedGameRoot, GetGameDataDirectory(game)); + } +} diff --git a/GenLauncherGO.Core/Startup/Models/GameInstallationLocation.cs b/GenLauncherGO.Core/Startup/Models/GameInstallationLocation.cs new file mode 100644 index 00000000..cf6b476f --- /dev/null +++ b/GenLauncherGO.Core/Startup/Models/GameInstallationLocation.cs @@ -0,0 +1,20 @@ +using System; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Core.Startup.Models; + +public sealed record GameInstallationLocation +{ + public GameInstallationLocation(SupportedGame game, string directory) + { + PerGame.EnsureSupported(game, nameof(game)); + + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + Game = game; + Directory = LexicalPath.NormalizeFullPath(directory); + } + + public SupportedGame Game { get; } + + public string Directory { get; } +} diff --git a/GenLauncherGO.Core/Startup/Models/GameInstallationValidationFailure.cs b/GenLauncherGO.Core/Startup/Models/GameInstallationValidationFailure.cs new file mode 100644 index 00000000..509fbad2 --- /dev/null +++ b/GenLauncherGO.Core/Startup/Models/GameInstallationValidationFailure.cs @@ -0,0 +1,42 @@ +namespace GenLauncherGO.Core.Startup.Models; + +/// +/// Identifies why a selected game installation cannot be used. +/// +public enum GameInstallationValidationFailure +{ + /// + /// The installation is valid. + /// + None = 0, + + /// + /// No installation directory was supplied. + /// + PathMissing = 1, + + /// + /// The supplied directory does not exist. + /// + DirectoryNotFound = 2, + + /// + /// The launcher executable directory is the game installation or one of its descendants. + /// + LauncherLocationOverlapsGame = 3, + + /// + /// The path traverses a reparse point and is unsafe for launcher mutations. + /// + UnsafeFileSystemPath = 4, + + /// + /// Windows could not safely resolve or inspect the directory. + /// + PathUnavailable = 5, + + /// + /// The directory does not contain a safe built-in executable for the selected game. + /// + BuiltInExecutableNotFound = 6 +} diff --git a/GenLauncherGO.Core/Startup/Models/GameInstallationValidationResult.cs b/GenLauncherGO.Core/Startup/Models/GameInstallationValidationResult.cs new file mode 100644 index 00000000..47a9941f --- /dev/null +++ b/GenLauncherGO.Core/Startup/Models/GameInstallationValidationResult.cs @@ -0,0 +1,42 @@ +using System; + +namespace GenLauncherGO.Core.Startup.Models; + +/// +/// Describes an actionable game-installation validation outcome. +/// +public sealed record GameInstallationValidationResult +{ + private GameInstallationValidationResult( + GameInstallationValidationFailure failure, + string? canonicalPath) + { + Failure = failure; + CanonicalPath = canonicalPath; + } + + public bool IsValid => Failure == GameInstallationValidationFailure.None; + + public GameInstallationValidationFailure Failure { get; } + + public string? CanonicalPath { get; } + + public static GameInstallationValidationResult Valid(string canonicalPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(canonicalPath); + + return new GameInstallationValidationResult( + GameInstallationValidationFailure.None, + canonicalPath); + } + + public static GameInstallationValidationResult Invalid(GameInstallationValidationFailure failure) + { + if (failure == GameInstallationValidationFailure.None) + { + throw new ArgumentOutOfRangeException(nameof(failure), failure, "A validation failure is required."); + } + + return new GameInstallationValidationResult(failure, null); + } +} diff --git a/GenLauncherGO.Core/Startup/Models/LauncherInstallationsValidationResult.cs b/GenLauncherGO.Core/Startup/Models/LauncherInstallationsValidationResult.cs new file mode 100644 index 00000000..fe8a07de --- /dev/null +++ b/GenLauncherGO.Core/Startup/Models/LauncherInstallationsValidationResult.cs @@ -0,0 +1,35 @@ +using System; +using GenLauncherGO.Core.Settings.Models; + +namespace GenLauncherGO.Core.Startup.Models; + +/// +/// Describes validated per-game paths and the complete canonical installation-set outcome. +/// +public sealed record LauncherInstallationsValidationResult +{ + internal LauncherInstallationsValidationResult( + GameInstallationValidationResult generalsValidation, + GameInstallationValidationResult zeroHourValidation, + LauncherInstallations canonicalInstallations, + bool hasDuplicatePath, + bool isValid) + { + GeneralsValidation = generalsValidation ?? throw new ArgumentNullException(nameof(generalsValidation)); + ZeroHourValidation = zeroHourValidation ?? throw new ArgumentNullException(nameof(zeroHourValidation)); + CanonicalInstallations = canonicalInstallations ?? + throw new ArgumentNullException(nameof(canonicalInstallations)); + HasDuplicatePath = hasDuplicatePath; + IsValid = isValid; + } + + public GameInstallationValidationResult GeneralsValidation { get; } + + public GameInstallationValidationResult ZeroHourValidation { get; } + + public LauncherInstallations CanonicalInstallations { get; } + + public bool HasDuplicatePath { get; } + + public bool IsValid { get; } +} diff --git a/GenLauncherGO.Core/Startup/Models/LauncherRestartResult.cs b/GenLauncherGO.Core/Startup/Models/LauncherRestartResult.cs new file mode 100644 index 00000000..eac4c292 --- /dev/null +++ b/GenLauncherGO.Core/Startup/Models/LauncherRestartResult.cs @@ -0,0 +1,27 @@ +using System; + +namespace GenLauncherGO.Core.Startup.Models; + +/// +/// Reports whether launching the replacement process for an application restart succeeded. +/// +public sealed record LauncherRestartResult +{ + private LauncherRestartResult(bool succeeded, string? errorMessage) + { + Succeeded = succeeded; + ErrorMessage = errorMessage; + } + + public bool Succeeded { get; } + + public string? ErrorMessage { get; } + + public static LauncherRestartResult Success { get; } = new(true, null); + + public static LauncherRestartResult Failure(string errorMessage) + { + ArgumentException.ThrowIfNullOrWhiteSpace(errorMessage); + return new LauncherRestartResult(false, errorMessage); + } +} diff --git a/GenLauncherGO.Core/Startup/PerGame.cs b/GenLauncherGO.Core/Startup/PerGame.cs new file mode 100644 index 00000000..46be56af --- /dev/null +++ b/GenLauncherGO.Core/Startup/PerGame.cs @@ -0,0 +1,54 @@ +using System; + +namespace GenLauncherGO.Core.Startup; + +/// +/// Selects values keyed by and owns the rejection of unsupported values. +/// +/// +/// Generals and Zero Hour stay explicit named members on the types that hold them: the launcher manages exactly +/// these two games, both are named in the persisted YAML, and every consumer wants them addressable by name. What +/// the pair sites genuinely shared was the rejection of and its message, +/// which was written out at each site and now lives here once. +/// +public static class PerGame +{ + /// + /// Returns the value belonging to a supported game. + /// + /// + /// Both arguments are evaluated before the selection, so callers whose branches allocate — a record + /// with expression, for instance — should switch directly and throw in the + /// default arm instead. + /// + /// Thrown when is not supported. + public static T Select(SupportedGame game, T generals, T zeroHour, string paramName = "game") + { + return game switch + { + SupportedGame.Generals => generals, + SupportedGame.ZeroHour => zeroHour, + _ => throw Unsupported(game, paramName) + }; + } + + /// + /// Rejects a game value the launcher does not manage. + /// + /// Thrown when is not supported. + public static void EnsureSupported(SupportedGame game, string paramName = "game") + { + if (game is not SupportedGame.Generals and not SupportedGame.ZeroHour) + { + throw Unsupported(game, paramName); + } + } + + /// + /// Creates the rejection for a game value the launcher does not manage. + /// + public static ArgumentOutOfRangeException Unsupported(SupportedGame game, string paramName = "game") + { + return new ArgumentOutOfRangeException(paramName, game, "A supported game is required."); + } +} diff --git a/GenLauncherGO.Core/Startup/SupportedGame.cs b/GenLauncherGO.Core/Startup/SupportedGame.cs new file mode 100644 index 00000000..a67300e7 --- /dev/null +++ b/GenLauncherGO.Core/Startup/SupportedGame.cs @@ -0,0 +1,22 @@ +namespace GenLauncherGO.Core.Startup; + +/// +/// 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/IPackageDownloadService.cs b/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadService.cs new file mode 100644 index 00000000..40318ea2 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadService.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Downloads and installs launcher-managed modification packages. +/// +public interface IPackageDownloadService +{ + /// + /// Downloads and installs one package, reporting progress until the returned task completes. + /// + Task DownloadAsync( + LauncherContent modification, + LauncherContentVersion version, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null); +} diff --git a/GenLauncherGO.Core/Updating/Contracts/IRemotePackageSizeResolver.cs b/GenLauncherGO.Core/Updating/Contracts/IRemotePackageSizeResolver.cs new file mode 100644 index 00000000..9075ce5f --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/IRemotePackageSizeResolver.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Resolves the total fresh-install payload size advertised by a remote launcher package source. +/// +/// +/// Implementations inspect remote metadata only and must not start or stage a package download. +/// +public interface IRemotePackageSizeResolver +{ + /// + /// Resolves the total remote payload size, or returns when the source cannot provide it. + /// + Task GetTotalBytesAsync( + LauncherContentVersion version, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Updating/Models/PackageDownloadPauseController.cs b/GenLauncherGO.Core/Updating/Models/PackageDownloadPauseController.cs new file mode 100644 index 00000000..8c4042a4 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadPauseController.cs @@ -0,0 +1,85 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Provides cooperative asynchronous pause and resume control for one package download. +/// +public sealed class PackageDownloadPauseController +{ + private readonly Lock _syncRoot = new(); + + private TaskCompletionSource? _resumeCompletion; + + public bool IsPaused + { + get + { + lock (_syncRoot) + { + return _resumeCompletion != null; + } + } + } + + /// + /// Pauses cooperative download work at its next checkpoint and reports whether the state changed. + /// + public bool Pause() + { + lock (_syncRoot) + { + if (_resumeCompletion != null) + { + return false; + } + + _resumeCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + return true; + } + } + + /// + /// Resumes paused download work and reports whether the state changed. + /// + public bool Resume() + { + TaskCompletionSource? resumeCompletion; + lock (_syncRoot) + { + resumeCompletion = _resumeCompletion; + _resumeCompletion = null; + } + + return resumeCompletion?.TrySetResult() == true; + } + + /// + /// Asynchronously waits until download work is resumed. + /// + public async ValueTask WaitWhilePausedAsync(CancellationToken cancellationToken) + { + Task? resumeTask; + lock (_syncRoot) + { + resumeTask = _resumeCompletion?.Task; + } + + if (resumeTask != null) + { + await resumeTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Waits at a cooperative pause checkpoint when a controller was supplied. + /// + public static ValueTask WaitWhilePausedAsync( + PackageDownloadPauseController? controller, + CancellationToken cancellationToken) + { + return controller?.WaitWhilePausedAsync(cancellationToken) ?? ValueTask.CompletedTask; + } +} diff --git a/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs b/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs new file mode 100644 index 00000000..b639e8e0 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs @@ -0,0 +1,63 @@ +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes the single, terminal outcome of a launcher package download. +/// +public sealed record PackageDownloadResult +{ + private PackageDownloadResult( + PackageDownloadStatus status, + string message) + { + Status = status; + Message = message; + } + + public PackageDownloadStatus Status { get; } + + public string Message { get; } + + public static PackageDownloadResult Succeeded() + { + return new PackageDownloadResult( + PackageDownloadStatus.Succeeded, + string.Empty); + } + + public static PackageDownloadResult Canceled() + { + return new PackageDownloadResult( + PackageDownloadStatus.Canceled, + string.Empty); + } + + /// + /// Creates a result for a download the launcher stopped to shut down, keeping its partial content to resume. + /// + public static PackageDownloadResult Suspended() + { + return new PackageDownloadResult( + PackageDownloadStatus.Suspended, + string.Empty); + } + + /// + /// Creates an expected failure result that can normally be retried or corrected by the user. + /// + public static PackageDownloadResult RecoverableFailure(string message) + { + return new PackageDownloadResult( + PackageDownloadStatus.RecoverableFailure, + message); + } + + /// + /// Creates an unexpected failure result while preserving diagnostic detail. + /// + public static PackageDownloadResult UnexpectedFailure(string message) + { + return new PackageDownloadResult( + PackageDownloadStatus.UnexpectedFailure, + message); + } +} diff --git a/GenLauncherGO.Core/Updating/Models/PackageDownloadStatus.cs b/GenLauncherGO.Core/Updating/Models/PackageDownloadStatus.cs new file mode 100644 index 00000000..9ccfc954 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadStatus.cs @@ -0,0 +1,36 @@ +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Identifies the single terminal status of a package download. +/// +public enum PackageDownloadStatus +{ + /// + /// The package was downloaded, verified, and installed. + /// + Succeeded, + + /// + /// The caller cooperatively canceled the operation before installation committed. + /// + Canceled, + + /// + /// The launcher stopped the operation to shut down and deliberately kept its partial content for resuming. + /// + /// + /// Distinct from precisely because cancellation discards partial content: the transport + /// stops the same way, but nothing is cleaned up afterwards. + /// + Suspended, + + /// + /// An expected provider, package, or local-environment condition prevented installation. + /// + RecoverableFailure, + + /// + /// An unexpected failure prevented installation and was recorded for diagnostics. + /// + UnexpectedFailure +} diff --git a/GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs b/GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs new file mode 100644 index 00000000..8cb10953 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs @@ -0,0 +1,11 @@ +using System; + +namespace GenLauncherGO.Core.Updating.Models; + +public sealed record PackageUpdateProgress( + long? TotalBytes, + long BytesRead, + double? ProgressPercentage, + string? FileName, + double? DownloadSpeedBytesPerSecond = null, + TimeSpan? EstimatedTimeRemaining = null); diff --git a/GenLauncherGO.Infrastructure/AGENTS.md b/GenLauncherGO.Infrastructure/AGENTS.md new file mode 100644 index 00000000..463b9912 --- /dev/null +++ b/GenLauncherGO.Infrastructure/AGENTS.md @@ -0,0 +1,12 @@ +# GenLauncherGO.Infrastructure Guidance + +- Keep concrete disk, network, archive, process, hashing, persistence, and logging implementations here; do not drive + Avalonia or other UI workflows. +- Bind the external YAML contract with exact transport DTOs, then map it once into normalized concepts. Preserve + accepted legacy keys, defaults, nesting, and values. +- Before traversing or mutating owned content, reuse the existing containment and path-safety primitives and fail + closed when safety cannot be proven. +- Preserve atomic writes, staging cleanup, deployment journaling, rollback, recovery, and hard-link-to-copy fallback + behavior. +- Use structured `ILogger` diagnostics around meaningful side effects and failures. Do not log credentials, tokens, + or unnecessary full user paths. diff --git a/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs b/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs new file mode 100644 index 00000000..1439f656 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs @@ -0,0 +1,134 @@ +using System; +using System.IO; +using System.Threading; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Infrastructure.Archives.Contracts; +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. +/// +internal sealed class ArchiveExtractor : IArchiveExtractor +{ + private readonly ILogger _logger; + + public ArchiveExtractor(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + public void ExtractToDirectory( + string archiveFilePath, + string destinationDirectory, + bool convertBigFilesToGib = false, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(archiveFilePath); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationDirectory); + + string destinationRoot = LexicalPath.NormalizeFullPath(destinationDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationRoot, + "Archive extraction destinations"); + Directory.CreateDirectory(destinationRoot); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + destinationRoot, + "Archive extraction destinations"); + + _logger.LogDebug( + "Extracting archive {ArchiveFilePath} to {DestinationDirectory}. Convert .big files to .gib: {ConvertBigFilesToGib}", + Path.GetFileName(archiveFilePath), + Path.GetFileName(destinationRoot), + convertBigFilesToGib); + + 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, convertBigFilesToGib); + string? entryDirectory = Path.GetDirectoryName(entryPath); + if (!string.IsNullOrWhiteSpace(entryDirectory)) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + entryDirectory, + "Archive entry paths"); + Directory.CreateDirectory(entryDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + entryDirectory, + "Archive entry paths"); + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + entryPath, + "Archive entry paths"); + entry.WriteToFile(entryPath, new ExtractionOptions + { + Overwrite = true, + PreserveFileTime = true + }); + + extractedEntryCount++; + } + + _logger.LogDebug( + "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. + /// + /// + /// 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, + bool convertBigFilesToGib) + { + if (string.IsNullOrWhiteSpace(entryKey)) + { + throw new InvalidDataException("Archive entry is missing a file name."); + } + + string normalizedEntryKey = entryKey.Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + + string containmentFailureMessage = + $"Archive entry '{entryKey}' would extract outside the destination folder."; + string destinationPath = LexicalPath.ResolveContainedPath( + destinationRoot, + normalizedEntryKey, + containmentFailureMessage); + if (convertBigFilesToGib) + { + destinationPath = BigFileVariantPath.GetInstalledPath(destinationPath); + destinationPath = LexicalPath.ResolveContainedPath( + destinationRoot, + destinationPath, + containmentFailureMessage); + } + + return destinationPath; + } +} diff --git a/GenLauncherGO.Infrastructure/Archives/Contracts/IArchiveExtractor.cs b/GenLauncherGO.Infrastructure/Archives/Contracts/IArchiveExtractor.cs new file mode 100644 index 00000000..1638a461 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Archives/Contracts/IArchiveExtractor.cs @@ -0,0 +1,32 @@ +using System; +using System.IO; +using System.Threading; + +namespace GenLauncherGO.Infrastructure.Archives.Contracts; + +internal interface IArchiveExtractor +{ + /// + /// Extracts an archive into the specified destination directory, creating directories and overwriting existing + /// extracted files when needed. Entries may not escape the destination, and extraction can optionally rename + /// .big files to .gib. + /// + /// + /// 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, + bool convertBigFilesToGib = false, + CancellationToken cancellationToken = default); +} diff --git a/GenLauncherGO.Infrastructure/Common/BigFileVariantPath.cs b/GenLauncherGO.Infrastructure/Common/BigFileVariantPath.cs new file mode 100644 index 00000000..fafb46b1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Common/BigFileVariantPath.cs @@ -0,0 +1,101 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Common; + +/// +/// Resolves manifest, installed, deployment, and in-progress path variants for package .big files. +/// +internal static class BigFileVariantPath +{ + /// + /// Returns the installed path, converting a .big path to its .gib variant. + /// + public static string GetInstalledPath(string filePath) + { + return IsBigFilePath(filePath) + ? GetGibVariantPath(filePath) + : filePath; + } + + /// + /// Returns the deployment path, converting an installed .gib path to its game-facing .big variant. + /// + public static string GetDeploymentPath(string filePath) + { + return IsGibFilePath(filePath) + ? Path.ChangeExtension(filePath, LauncherContentFileTypes.BigExtension) + : filePath; + } + + /// + /// Returns the same-base .gib variant for a requested path. + /// + public static string GetGibVariantPath(string filePath) + { + return Path.ChangeExtension(filePath, LauncherContentFileTypes.GibExtension); + } + + /// + /// Returns the existing downloaded path, preferring the requested path and then the converted .gib path. + /// + public static string GetExistingDownloadedPath(string destinationFilePath) + { + if (File.Exists(destinationFilePath)) + { + return destinationFilePath; + } + + string gibFilePath = GetGibVariantPath(destinationFilePath); + return File.Exists(gibFilePath) ? gibFilePath : string.Empty; + } + + /// + /// Converts a downloaded .big file to its installed .gib path. + /// + public static void ConvertBigFileToGib(string destinationFilePath) + { + if (!IsBigFilePath(destinationFilePath)) + { + return; + } + + string gibFilePath = GetGibVariantPath(destinationFilePath); + 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. + /// + public static void PrepareBigFileResumePath(string destinationFilePath) + { + if (!IsBigFilePath(destinationFilePath)) + { + return; + } + + string gibFilePath = GetGibVariantPath(destinationFilePath); + if (!File.Exists(gibFilePath) || File.Exists(destinationFilePath)) + { + return; + } + + File.Move(gibFilePath, destinationFilePath); + } + + private static bool IsBigFilePath(string filePath) + { + return string.Equals(Path.GetExtension(filePath), LauncherContentFileTypes.BigExtension, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsGibFilePath(string filePath) + { + return string.Equals(Path.GetExtension(filePath), LauncherContentFileTypes.GibExtension, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs b/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs new file mode 100644 index 00000000..f2fa2907 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Infrastructure.Common; + +/// +/// Provides shared filesystem path-safety checks for infrastructure services. +/// +/// +/// Every check names the paths it guards with a plural noun phrase — "Deployment journal paths", "Package +/// staging paths" — and builds its rejection message from that phrase. Callers pass the subject rather than +/// finished sentences so one wording change reaches every safety failure in the launcher. +/// +internal static class FileSystemPathSafety +{ + /// + /// Resolves a candidate path and verifies that it stays within an owned root without traversing existing links. + /// + /// The launcher-owned directory the candidate must remain inside. + /// The path to resolve and verify. + /// The plural noun phrase naming the guarded paths. + /// + /// The container named by the containment failure, such as "the deployment directory". + /// + public static string ResolveOwnedSubpath( + string ownedRoot, + string candidatePath, + string pathSubject, + string ownerDescription) + { + string normalizedRoot = LexicalPath.NormalizeFullPath(ownedRoot); + string normalizedCandidate = LexicalPath.NormalizeFullPath(candidatePath); + if (!LexicalPath.IsPathInDirectory(normalizedCandidate, normalizedRoot)) + { + throw new InvalidDataException($"{pathSubject} must stay inside {ownerDescription}."); + } + + EnsureExistingPathChainHasNoReparsePoints(normalizedRoot, pathSubject); + EnsureExistingPathChainHasNoReparsePoints(normalizedCandidate, pathSubject); + + return normalizedCandidate; + } + + /// + /// Rejects paths whose existing filesystem chain contains a reparse point. + /// + /// The path whose existing ancestors are inspected. + /// The plural noun phrase naming the guarded paths. + public static void EnsureExistingPathChainHasNoReparsePoints(string path, string pathSubject) + { + if (ExistingPathChainContainsReparsePoint(path, pathSubject)) + { + throw new InvalidDataException(CreateLinkedPathMessage(pathSubject)); + } + } + + /// + /// Rejects a directory tree whose root or child entries contain a reparse point. + /// + /// The root of the tree to inspect. + /// The plural noun phrase naming the guarded paths. + public static void EnsureDirectoryTreeHasNoReparsePoints(string directoryPath, string pathSubject) + { + string rootPath = NormalizeAndValidateTreeRoot(directoryPath, pathSubject); + InspectDirectoryChildren(rootPath, pathSubject, null); + } + + /// + /// Returns every file below a directory after rejecting reparse points anywhere in the tree. + /// + /// The root of the tree to enumerate. + /// The plural noun phrase naming the guarded paths. + public static IReadOnlyList GetDirectoryFilesWithNoReparsePoints( + string directoryPath, + string pathSubject) + { + string rootPath = NormalizeAndValidateTreeRoot(directoryPath, pathSubject); + var files = new List(); + InspectDirectoryChildren(rootPath, pathSubject, files); + return files; + } + + /// + /// Determines whether an existing path chain contains a reparse point. + /// + /// The path whose existing ancestors are inspected. + /// The plural noun phrase naming the guarded paths. + public static bool ExistingPathChainContainsReparsePoint(string path, string pathSubject) + { + string fullPath = LexicalPath.NormalizeFullPath(path); + string root = Path.GetPathRoot(fullPath) + ?? throw new InvalidDataException(CreateUnrootedPathMessage(pathSubject)); + string relativePath = LexicalPath.GetRelativePath(root, fullPath); + if (relativePath == ".") + { + return false; + } + + string currentPath = root; + string[] segments = relativePath.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + foreach (string segment in segments) + { + currentPath = Path.Combine(currentPath, segment); + if (!TryGetAttributes(currentPath, out FileAttributes attributes)) + { + return false; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + return true; + } + } + + return false; + } + + /// + /// Determines whether a filesystem entry is a reparse point. + /// + public static bool IsReparsePoint(string path) + { + return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; + } + + /// + /// Creates recursive enumeration options that never traverse reparse points. + /// + public static EnumerationOptions CreateRecursiveNoLinksOptions() + { + return new EnumerationOptions + { + AttributesToSkip = FileAttributes.ReparsePoint, + IgnoreInaccessible = false, + RecurseSubdirectories = true, + ReturnSpecialDirectories = false + }; + } + + private static string CreateUnrootedPathMessage(string pathSubject) + { + return $"{pathSubject} must be rooted."; + } + + private static string CreateLinkedPathMessage(string pathSubject) + { + return $"{pathSubject} must not contain reparse points."; + } + + private static string NormalizeAndValidateTreeRoot(string directoryPath, string pathSubject) + { + string rootPath = LexicalPath.NormalizeFullPath(directoryPath); + if (IsReparsePoint(rootPath)) + { + throw new InvalidDataException(CreateLinkedPathMessage(pathSubject)); + } + + return rootPath; + } + + internal static bool TryGetAttributes(string path, out FileAttributes attributes) + { + try + { + attributes = File.GetAttributes(path); + return true; + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + attributes = default; + return false; + } + } + + private static void InspectDirectoryChildren( + string directoryPath, + string pathSubject, + ICollection? files) + { + foreach (string entryPath in Directory.EnumerateFileSystemEntries(directoryPath)) + { + FileAttributes attributes = File.GetAttributes(entryPath); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidDataException(CreateLinkedPathMessage(pathSubject)); + } + + if ((attributes & FileAttributes.Directory) != 0) + { + InspectDirectoryChildren(entryPath, pathSubject, files); + } + else + { + files?.Add(entryPath); + } + } + } +} diff --git a/GenLauncherGO.Infrastructure/Common/ManifestPathResolver.cs b/GenLauncherGO.Infrastructure/Common/ManifestPathResolver.cs new file mode 100644 index 00000000..452e6c61 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Common/ManifestPathResolver.cs @@ -0,0 +1,132 @@ +using System; +using System.IO; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Infrastructure.Common; + +/// +/// Defines the relative-path grammar shared by remote package manifests and durable deployment manifests. +/// +internal static class ManifestPathResolver +{ + /// + /// Resolves a remote manifest file name to a full path under the specified root directory. + /// + /// + /// Thrown when the root directory or manifest file name is empty, rooted, drive-qualified, or contains a current + /// or parent directory segment. + /// + /// + /// Thrown when the resolved path would leave . + /// + public static string ResolvePath(string rootDirectory, string manifestFileName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(manifestFileName); + + return LexicalPath.ResolveContainedPath( + rootDirectory, + NormalizeRelativePath(manifestFileName), + $"Manifest file '{manifestFileName}' would resolve outside the package directory."); + } + + /// + /// Resolves the installed path for a remote manifest file, including the canonical .big-to-.gib + /// conversion. + /// + public static string ResolveInstalledPath(string rootDirectory, string manifestFileName) + { + return BigFileVariantPath.GetInstalledPath(ResolvePath(rootDirectory, manifestFileName)); + } + + /// + /// Normalizes a remote manifest path to the current platform directory separator after validation. + /// + /// + /// Thrown when the path is rooted, drive-qualified, empty, or contains a current or parent directory segment. + /// + public static string NormalizeRelativePath(string manifestFileName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(manifestFileName); + + return NormalizeRelativePathCore( + manifestFileName.Trim(), + "Manifest file paths", + message => new ArgumentException(message, nameof(manifestFileName))); + } + + /// + /// Normalizes a remote manifest path to slash separators for manifest index lookups. + /// + public static string NormalizeForManifestIndex(string manifestFileName) + { + return LexicalPath.NormalizeRelativePath(NormalizeRelativePath(manifestFileName)); + } + + /// + /// Normalizes the installed relative path used for manifest lookups, including the canonical + /// .big-to-.gib conversion. + /// + public static string NormalizeInstalledPathForManifestIndex(string manifestFileName) + { + return BigFileVariantPath.GetInstalledPath(NormalizeForManifestIndex(manifestFileName)); + } + + /// + /// Normalizes a deployment manifest path to slash separators while preserving its durable-state error contract. + /// + /// Thrown when the path is empty or whitespace. + /// + /// Thrown when the path is rooted, drive-qualified, or contains a current or parent directory segment. + /// + public static string NormalizeForDeploymentManifest(string relativePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + + return LexicalPath.NormalizeRelativePath( + NormalizeRelativePathCore( + relativePath, + "Deployment manifest paths", + message => new InvalidDataException(message))); + } + + /// + /// Applies the shared relative-path grammar, letting each caller name its paths and choose its failure type. + /// + /// + /// Remote manifest names are caller input and fail as ; deployment manifest + /// paths are read back from durable journal state, where the same malformed value means corrupt data. Only + /// the exception type and the subject differ, so the grammar itself lives here once. + /// + private static string NormalizeRelativePathCore( + string relativePath, + string pathSubject, + Func createValidationException) + { + if (Path.IsPathRooted(relativePath) || + relativePath.Contains(':', StringComparison.Ordinal)) + { + throw createValidationException($"{pathSubject} must be relative."); + } + + string[] segments = relativePath.Split( + ['/', '\\'], + StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + { + throw createValidationException($"{pathSubject} must include a file name."); + } + + foreach (string segment in segments) + { + if (string.Equals(segment, ".", StringComparison.Ordinal) || + string.Equals(segment, "..", StringComparison.Ordinal)) + { + throw createValidationException( + $"{pathSubject} must not contain parent directory segments."); + } + } + + return Path.Combine(segments); + } +} diff --git a/GenLauncherGO.Infrastructure/Common/OwnedDirectoryTree.cs b/GenLauncherGO.Infrastructure/Common/OwnedDirectoryTree.cs new file mode 100644 index 00000000..1b14fb62 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Common/OwnedDirectoryTree.cs @@ -0,0 +1,430 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Common; + +/// +/// Mutates explicitly launcher-owned directory trees without traversing reparse points. +/// +internal static class OwnedDirectoryTree +{ + /// + /// Creates an owned directory when it does not exist and rejects an existing linked entry. + /// + public static string EnsureExists(string ownedRoot, string directoryPath) + { + string normalizedPath = ResolveOwnedDirectoryPath(ownedRoot, directoryPath); + EnsureSafeAncestors(ownedRoot, normalizedPath); + if (FileSystemPathSafety.TryGetAttributes(normalizedPath, out FileAttributes attributes)) + { + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidDataException("Owned directories must not be reparse points."); + } + + if ((attributes & FileAttributes.Directory) == 0) + { + throw new IOException("An owned directory path is occupied by a file."); + } + + return normalizedPath; + } + + Directory.CreateDirectory(normalizedPath); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + normalizedPath, + "Owned directory paths"); + return normalizedPath; + } + + /// + /// Ensures an owned path is a real directory, replacing a linked leaf with an empty real directory. + /// + public static string EnsureRealDirectory(string ownedRoot, string directoryPath) + { + string normalizedPath = ResolveOwnedDirectoryPath(ownedRoot, directoryPath); + EnsureSafeAncestors(ownedRoot, normalizedPath); + + if (!FileSystemPathSafety.TryGetAttributes(normalizedPath, out FileAttributes attributes)) + { + Directory.CreateDirectory(normalizedPath); + return normalizedPath; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + DeleteEntryWithoutFollowing(normalizedPath, attributes); + Directory.CreateDirectory(normalizedPath); + return normalizedPath; + } + + if ((attributes & FileAttributes.Directory) == 0) + { + throw new IOException("An owned directory path is occupied by a file."); + } + + return normalizedPath; + } + + /// + /// Deletes an owned directory tree when it exists. + /// + /// + /// Nested links are deleted as entries without traversing them, so their targets remain untouched. + /// + public static bool DeleteIfExists(OwnedContentPath ownedPath) + { + ArgumentNullException.ThrowIfNull(ownedPath); + + return DeleteIfExists(ownedPath.OwnerRoot, ownedPath.FullPath); + } + + /// + /// Deletes a directory tree below an explicit owned root when it exists. + /// + /// + /// Nested links are deleted as entries without traversing them, so their targets remain untouched. + /// + public static bool DeleteIfExists(string ownedRoot, string directoryPath) + { + string normalizedPath = ResolveOwnedDirectoryPath(ownedRoot, directoryPath); + EnsureSafeAncestors(ownedRoot, normalizedPath); + if (!FileSystemPathSafety.TryGetAttributes(normalizedPath, out FileAttributes attributes)) + { + return false; + } + + DeleteEntryWithoutFollowing(normalizedPath, attributes); + return true; + } + + /// + /// Creates an owned directory when necessary and deletes all of its existing child entries. + /// + /// + /// When the directory itself is a link, the link is deleted and replaced by a real directory. Nested links are + /// deleted as entries without traversing them. + /// + public static string PrepareEmpty(string ownedRoot, string directoryPath) + { + string normalizedPath = ResolveOwnedDirectoryPath(ownedRoot, directoryPath); + EnsureSafeAncestors(ownedRoot, normalizedPath); + + if (FileSystemPathSafety.TryGetAttributes(normalizedPath, out FileAttributes attributes)) + { + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + DeleteEntryWithoutFollowing(normalizedPath, attributes); + Directory.CreateDirectory(normalizedPath); + return normalizedPath; + } + + if ((attributes & FileAttributes.Directory) == 0) + { + throw new IOException("An owned directory path is occupied by a file."); + } + + DeleteDirectoryChildren(normalizedPath); + return normalizedPath; + } + + Directory.CreateDirectory(normalizedPath); + return normalizedPath; + } + + /// + /// Deletes empty parent directories between a child path and its exclusive ownership boundary. + /// + public static IReadOnlyList DeleteEmptyParents(string ownedRoot, string childPath) + { + string normalizedRoot = LexicalPath.NormalizeFullPath(ownedRoot); + string normalizedChild = LexicalPath.NormalizeFullPath(childPath); + if (!LexicalPath.IsPathInDirectory(normalizedChild, normalizedRoot)) + { + throw new InvalidOperationException("Refusing to prune directories outside the owned root."); + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + normalizedRoot, + "Owned directory roots"); + + var deletedDirectories = new List(); + DirectoryInfo? current = Directory.GetParent(normalizedChild); + while (current is not null && + !LexicalPath.AreEquivalent(current.FullName, normalizedRoot)) + { + string currentPath = current.FullName; + DirectoryInfo? parent = current.Parent; + if (!LexicalPath.IsPathInDirectory(currentPath, normalizedRoot)) + { + break; + } + + if (!FileSystemPathSafety.TryGetAttributes(currentPath, out FileAttributes attributes)) + { + current = parent; + continue; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidDataException("Owned directory parents must not contain reparse points."); + } + + if ((attributes & FileAttributes.Directory) == 0 || + Directory.EnumerateFileSystemEntries(currentPath).Any()) + { + break; + } + + Directory.Delete(currentPath, false); + deletedDirectories.Add(currentPath); + current = parent; + } + + return deletedDirectories; + } + + /// + /// Deletes empty parent directories and their exclusive ownership boundary when the complete chain is empty. + /// + public static IReadOnlyList DeleteEmptyParentsIncludingRoot(string ownedRoot, string childPath) + { + string normalizedRoot = LexicalPath.NormalizeFullPath(ownedRoot); + var deletedDirectories = DeleteEmptyParents(normalizedRoot, childPath).ToList(); + if (!FileSystemPathSafety.TryGetAttributes(normalizedRoot, out FileAttributes attributes) || + (attributes & FileAttributes.Directory) == 0) + { + return deletedDirectories; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidDataException("Owned directory roots must not be reparse points."); + } + + if (!Directory.EnumerateFileSystemEntries(normalizedRoot).Any()) + { + Directory.Delete(normalizedRoot, false); + deletedDirectories.Add(normalizedRoot); + } + + return deletedDirectories; + } + + /// + /// Recursively deletes empty real directories below an owned content path without traversing or deleting links. + /// + public static bool DeleteEmptyDirectories(OwnedContentPath ownedPath) + { + ArgumentNullException.ThrowIfNull(ownedPath); + + EnsureSafeAncestors(ownedPath.OwnerRoot, ownedPath.FullPath); + return DeleteEmptyDirectoriesCore(ownedPath.FullPath); + } + + /// + /// Recursively deletes empty real directories, including an exclusive launcher-owned root when it becomes empty. + /// + public static bool DeleteEmptyDirectories(string ownedRoot) + { + string normalizedRoot = LexicalPath.NormalizeFullPath(ownedRoot); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + normalizedRoot, + "Owned directory roots"); + return DeleteEmptyDirectoriesCore(normalizedRoot); + } + + /// + /// Deletes all reparse-point entries below an owned real directory without traversing their targets. + /// + public static IReadOnlyList DeleteReparsePoints(OwnedContentPath ownedPath) + { + ArgumentNullException.ThrowIfNull(ownedPath); + + EnsureSafeAncestors(ownedPath.OwnerRoot, ownedPath.FullPath); + var deletedPaths = new List(); + DeleteReparsePointsCore(ownedPath.FullPath, deletedPaths); + return deletedPaths; + } + + private static string ResolveOwnedDirectoryPath(string ownedRoot, string directoryPath) + { + return new OwnedContentPath(ownedRoot, directoryPath).FullPath; + } + + private static void EnsureSafeAncestors(string ownedRoot, string directoryPath) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + ownedRoot, + "Owned directory roots"); + + string? parentDirectory = Path.GetDirectoryName(directoryPath); + if (!string.IsNullOrWhiteSpace(parentDirectory)) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + parentDirectory, + "Owned directory paths"); + } + } + + /// + /// Empties an owned directory except for one child directory that must survive. + /// + /// + /// Used where a scratch directory also holds state a later session still needs, so the two cannot simply be + /// cleared together. + /// + public static string PrepareEmptyExcept(string ownedRoot, string directoryPath, string preservedChildPath) + { + string normalizedPath = ResolveOwnedDirectoryPath(ownedRoot, directoryPath); + EnsureSafeAncestors(ownedRoot, normalizedPath); + + if (!FileSystemPathSafety.TryGetAttributes(normalizedPath, out FileAttributes attributes) || + (attributes & FileAttributes.ReparsePoint) != 0 || + (attributes & FileAttributes.Directory) == 0) + { + return PrepareEmpty(ownedRoot, directoryPath); + } + + string normalizedPreservedPath = ResolveOwnedDirectoryPath(ownedRoot, preservedChildPath); + DeleteDirectoryChildren(normalizedPath, normalizedPreservedPath); + return normalizedPath; + } + + private static void DeleteDirectoryChildren(string directoryPath, string? preservedPath = null) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + directoryPath, + "Owned directory paths"); + + foreach (string entryPath in Directory.EnumerateFileSystemEntries(directoryPath).ToList()) + { + if (!FileSystemPathSafety.TryGetAttributes(entryPath, out FileAttributes attributes)) + { + continue; + } + + if (preservedPath != null && + LexicalPath.AreEquivalent(entryPath, preservedPath)) + { + continue; + } + + DeleteEntryWithoutFollowing(entryPath, attributes); + } + } + + private static bool DeleteEmptyDirectoriesCore(string directoryPath) + { + if (!FileSystemPathSafety.TryGetAttributes(directoryPath, out FileAttributes attributes) || + (attributes & FileAttributes.ReparsePoint) != 0 || + (attributes & FileAttributes.Directory) == 0) + { + return false; + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + directoryPath, + "Owned directory paths"); + + foreach (string entryPath in Directory.EnumerateDirectories(directoryPath).ToList()) + { + if (!FileSystemPathSafety.TryGetAttributes(entryPath, out FileAttributes childAttributes) || + (childAttributes & FileAttributes.ReparsePoint) != 0) + { + continue; + } + + DeleteEmptyDirectoriesCore(entryPath); + } + + if (Directory.EnumerateFileSystemEntries(directoryPath).Any()) + { + return false; + } + + Directory.Delete(directoryPath, false); + return true; + } + + private static void DeleteReparsePointsCore(string directoryPath, List deletedPaths) + { + if (!FileSystemPathSafety.TryGetAttributes(directoryPath, out FileAttributes directoryAttributes) || + (directoryAttributes & FileAttributes.ReparsePoint) != 0 || + (directoryAttributes & FileAttributes.Directory) == 0) + { + throw new InvalidDataException("Owned directory traversal requires a real directory."); + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + directoryPath, + "Owned directory paths"); + + foreach (string entryPath in Directory.EnumerateFileSystemEntries(directoryPath).ToList()) + { + if (!FileSystemPathSafety.TryGetAttributes(entryPath, out FileAttributes attributes)) + { + continue; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + DeleteLinkEntry(entryPath, attributes); + deletedPaths.Add(entryPath); + continue; + } + + if ((attributes & FileAttributes.Directory) != 0) + { + DeleteReparsePointsCore(entryPath, deletedPaths); + } + } + } + + private static void DeleteEntryWithoutFollowing(string entryPath, FileAttributes observedAttributes) + { + if ((observedAttributes & FileAttributes.ReparsePoint) != 0) + { + DeleteLinkEntry(entryPath, observedAttributes); + return; + } + + if ((observedAttributes & FileAttributes.Directory) != 0) + { + if (!FileSystemPathSafety.TryGetAttributes(entryPath, out FileAttributes currentAttributes)) + { + return; + } + + if ((currentAttributes & FileAttributes.ReparsePoint) != 0) + { + DeleteLinkEntry(entryPath, currentAttributes); + return; + } + + DeleteDirectoryChildren(entryPath); + Directory.Delete(entryPath, false); + return; + } + + File.Delete(entryPath); + } + + private static void DeleteLinkEntry(string entryPath, FileAttributes attributes) + { + if ((attributes & FileAttributes.Directory) != 0) + { + Directory.Delete(entryPath, false); + } + else + { + File.Delete(entryPath); + } + } + +} diff --git a/GenLauncherGO.Infrastructure/Common/PhysicalDirectoryPath.cs b/GenLauncherGO.Infrastructure/Common/PhysicalDirectoryPath.cs new file mode 100644 index 00000000..d2c7aeae --- /dev/null +++ b/GenLauncherGO.Infrastructure/Common/PhysicalDirectoryPath.cs @@ -0,0 +1,211 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace GenLauncherGO.Infrastructure.Common; + +/// +/// Identifies an existing file-system object independently of aliases in its path spelling. +/// +internal readonly record struct PhysicalFileSystemIdentity(uint VolumeSerialNumber, ulong FileIndex); + +/// +/// Resolves existing Windows directories through handles for security-sensitive comparisons and recovery metadata. +/// +internal static class PhysicalDirectoryPath +{ + private const uint FileReadAttributes = 0x0080; + private const uint FileShareRead = 0x00000001; + private const uint FileShareWrite = 0x00000002; + private const uint FileShareDelete = 0x00000004; + private const uint OpenExisting = 3; + private const uint FileFlagBackupSemantics = 0x02000000; + private const uint FileNameNormalized = 0x0; + private const uint VolumeNameDos = 0x0; + private const string ExtendedPathPrefix = @"\\?\"; + private const string ExtendedUncPrefix = @"\\?\UNC\"; + + /// + /// Returns the canonical path observed through a handle to an existing directory. + /// + public static string ResolveExisting(string path) + { + using SafeFileHandle handle = OpenDirectory(path); + var buffer = new StringBuilder(512); + uint requiredLength = GetFinalPathNameByHandle( + handle, + buffer, + (uint)buffer.Capacity, + FileNameNormalized | VolumeNameDos); + if (requiredLength == 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + if (requiredLength >= buffer.Capacity) + { + buffer.EnsureCapacity(checked((int)requiredLength + 1)); + requiredLength = GetFinalPathNameByHandle( + handle, + buffer, + (uint)buffer.Capacity, + FileNameNormalized | VolumeNameDos); + if (requiredLength == 0 || requiredLength >= buffer.Capacity) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + + return NormalizeHandlePath(buffer.ToString()); + } + + /// + /// Returns the stable volume and file-index identity of an existing directory. + /// + public static PhysicalFileSystemIdentity GetIdentity(string path) + { + using SafeFileHandle handle = OpenDirectory(path); + return GetIdentity(handle); + } + + /// + /// Returns the stable volume and file-index identity of an existing file. + /// + public static PhysicalFileSystemIdentity GetFileIdentity(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + string fullPath = Path.GetFullPath(path); + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException("The file does not exist.", fullPath); + } + + using SafeFileHandle handle = OpenExistingPathHandle(fullPath, 0); + return GetIdentity(handle); + } + + private static PhysicalFileSystemIdentity GetIdentity(SafeFileHandle handle) + { + if (!GetFileInformationByHandle(handle, out ByHandleFileInformation information)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + ulong fileIndex = ((ulong)information.FileIndexHigh << 32) | information.FileIndexLow; + return new PhysicalFileSystemIdentity(information.VolumeSerialNumber, fileIndex); + } + + private static SafeFileHandle OpenDirectory(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + string fullPath = Path.GetFullPath(path); + if (!Directory.Exists(fullPath)) + { + throw new DirectoryNotFoundException("The directory does not exist."); + } + + return OpenExistingPathHandle(fullPath, FileFlagBackupSemantics); + } + + private static SafeFileHandle OpenExistingPathHandle(string fullPath, uint flagsAndAttributes) + { + SafeFileHandle handle = CreateFile( + ToExtendedLengthPath(fullPath), + FileReadAttributes, + FileShareRead | FileShareWrite | FileShareDelete, + IntPtr.Zero, + OpenExisting, + flagsAndAttributes, + IntPtr.Zero); + if (handle.IsInvalid) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error); + } + + return handle; + } + + /// + /// Returns the absolute Win32 extended-length form of a local or UNC path. + /// + public static string ToExtendedLengthPath(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + string fullPath = Path.GetFullPath(path); + if (fullPath.StartsWith(ExtendedPathPrefix, StringComparison.Ordinal)) + { + return fullPath; + } + + return fullPath.StartsWith(@"\\", StringComparison.Ordinal) + ? ExtendedUncPrefix + fullPath[2..] + : ExtendedPathPrefix + fullPath; + } + + private static string NormalizeHandlePath(string path) + { + string normalizedPath; + if (path.StartsWith(ExtendedUncPrefix, StringComparison.OrdinalIgnoreCase)) + { + normalizedPath = @"\\" + path[ExtendedUncPrefix.Length..]; + } + else if (path.StartsWith(ExtendedPathPrefix, StringComparison.OrdinalIgnoreCase)) + { + normalizedPath = path[ExtendedPathPrefix.Length..]; + } + else + { + normalizedPath = path; + } + + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(normalizedPath)); + } + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", EntryPoint = "GetFinalPathNameByHandleW", SetLastError = true, + CharSet = CharSet.Unicode)] + private static extern uint GetFinalPathNameByHandle( + SafeFileHandle file, + StringBuilder filePath, + uint filePathLength, + uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle file, + out ByHandleFileInformation fileInformation); + + [StructLayout(LayoutKind.Sequential)] + private struct ByHandleFileInformation + { + public uint FileAttributes; + public FILETIME CreationTime; + public FILETIME LastAccessTime; + public FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } +} diff --git a/GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj b/GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj new file mode 100644 index 00000000..5967758d --- /dev/null +++ b/GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj @@ -0,0 +1,21 @@ + + + net10.0-windows + + + + + + + + + + + + + + + + + + diff --git a/GenLauncherGO.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/InfrastructureServiceCollectionExtensions.cs new file mode 100644 index 00000000..409d404c --- /dev/null +++ b/GenLauncherGO.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -0,0 +1,80 @@ +using System; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Shell.Contracts; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Infrastructure.Archives; +using GenLauncherGO.Infrastructure.Archives.Contracts; +using GenLauncherGO.Infrastructure.Integrity.Contracts; +using GenLauncherGO.Infrastructure.Integrity.Services; +using GenLauncherGO.Infrastructure.Launching.Contracts; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Launching.Support; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Infrastructure.Remote; +using GenLauncherGO.Infrastructure.Remote.Contracts; +using GenLauncherGO.Infrastructure.Shell.Services; +using GenLauncherGO.Infrastructure.Updating.Clients; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace GenLauncherGO.Infrastructure; + +/// +/// Registers the launcher runtime's Infrastructure services after storage and logging bootstrap is complete. +/// +public static class InfrastructureServiceCollectionExtensions +{ + public static IServiceCollection AddGenLauncherGoInfrastructure(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + 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/Integrity/Contracts/IContentIntegrityService.cs b/GenLauncherGO.Infrastructure/Integrity/Contracts/IContentIntegrityService.cs new file mode 100644 index 00000000..d57acd81 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Contracts/IContentIntegrityService.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Infrastructure.Integrity.Contracts; + +/// +/// Verifies, snapshots, and cleans launcher-owned content. +/// +internal interface IContentIntegrityService +{ + /// + /// Verifies all targets against trusted snapshots owned by the supplied immutable game namespace. + /// + Task VerifyAsync( + LauncherPaths paths, + IReadOnlyList targets, + CancellationToken cancellationToken); + + /// + /// Captures a trusted snapshot in the supplied immutable game namespace only when a target currently contains + /// exactly the expected safe file set. + /// + /// + /// when a snapshot was captured; otherwise, when the current + /// file set contains extras, missing files, empty directories, unsafe links, or unreadable entries. + /// + Task CaptureSnapshotIfMatchesExpectedFileSetAsync( + LauncherPaths paths, + ContentIntegrityTarget target, + IReadOnlySet expectedRelativePaths, + CancellationToken cancellationToken); + + /// + /// Replaces a target's trusted snapshot with its current safe directory contents. + /// + Task CaptureSnapshotAsync( + LauncherPaths paths, + ContentIntegrityTarget target, + CancellationToken cancellationToken); + + /// + /// Deletes managed entries explicitly listed for deletion in a verification report, resolving them only within + /// the verified targets. + /// + Task ApplyCleanupAsync( + ContentIntegrityReport report, + IReadOnlyList targets, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs b/GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs new file mode 100644 index 00000000..2bb651fd --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs @@ -0,0 +1,481 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Integrity.Contracts; +using GenLauncherGO.Infrastructure.Integrity.Support; +using GenLauncherGO.Infrastructure.Persistence.Services; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Integrity.Services; + +/// +/// Verifies launcher-owned content with SHA-256 snapshots and applies confirmed managed-content cleanup. +/// +internal sealed class FileSystemContentIntegrityService : IContentIntegrityService +{ + private readonly IAtomicFileWriter _atomicFileWriter; + private readonly ILogger _logger; + + public FileSystemContentIntegrityService( + IAtomicFileWriter atomicFileWriter, + ILogger logger) + { + _atomicFileWriter = atomicFileWriter ?? throw new ArgumentNullException(nameof(atomicFileWriter)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task VerifyAsync( + LauncherPaths paths, + IReadOnlyList targets, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(targets); + ContentIntegritySnapshotStore snapshotStore = CreateSnapshotStore(paths); + long verificationStartedTimestamp = Stopwatch.GetTimestamp(); + + if (targets.Count > 0) + { + _logger.LogInformation( + "Starting content integrity verification for {TargetCount} target(s).", + targets.Count); + } + else + { + _logger.LogDebug("Skipped content integrity verification because no targets were supplied."); + } + + List issues = []; + 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 or InvalidDataException) + { + _logger.LogError( + exception, + "Failed to read integrity snapshot for {TargetName}.", + target.DisplayName); + issues.Add(CreateVerificationError(target, exception.Message)); + continue; + } + + if (snapshot is null || snapshot.SourceKind != target.SourceKind) + { + _logger.LogWarning( + "Content integrity target {TargetName} is untracked or has changed source kind. Current source kind: {SourceKind}.", + target.DisplayName, + 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(CreateVerificationError(target, 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(CreateVerificationError(target, exception.Message)); + } + } + + if (targets.Count > 0) + { + _logger.LogInformation( + "Completed content integrity verification for {TargetCount} target(s) in {ElapsedMilliseconds} ms; issues: {IssueCount}.", + targets.Count, + (long)Stopwatch.GetElapsedTime(verificationStartedTimestamp).TotalMilliseconds, + issues.Count); + } + + return new ContentIntegrityReport(issues); + } + + public async Task CaptureSnapshotIfMatchesExpectedFileSetAsync( + LauncherPaths paths, + ContentIntegrityTarget target, + IReadOnlySet expectedRelativePaths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(expectedRelativePaths); + ContentIntegritySnapshotStore snapshotStore = CreateSnapshotStore(paths); + + ContentIntegrityScanResult scan = + await ContentIntegrityScanner.ScanAsync(target, cancellationToken).ConfigureAwait(false); + if (!ContentIntegrityScanner.MatchesExpectedFileSet(scan, expectedRelativePaths)) + { + _logger.LogWarning( + "Skipped integrity snapshot capture for {TargetName} because the scanned file set did not match the expected package manifest.", + target.DisplayName); + return false; + } + + await snapshotStore.WriteSnapshotAsync(target, scan, cancellationToken).ConfigureAwait(false); + return true; + } + + public async Task CaptureSnapshotAsync( + LauncherPaths paths, + ContentIntegrityTarget target, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(target); + ContentIntegritySnapshotStore snapshotStore = CreateSnapshotStore(paths); + + ContentIntegrityScanResult scan = + await ContentIntegrityScanner.ScanAsync(target, cancellationToken).ConfigureAwait(false); + if (scan.UnsafeLinks.Count > 0 || scan.Errors.Count > 0) + { + _logger.LogWarning( + "Blocked integrity snapshot capture for {TargetName}; unsafe links: {UnsafeLinkCount}; errors: {ErrorCount}.", + target.DisplayName, + scan.UnsafeLinks.Count, + scan.Errors.Count); + 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); + var deleteIssues = report.Issues + .Where(issue => issue.Action == IntegrityIssueAction.Delete) + .ToList(); + + _logger.LogInformation( + "Applying content integrity cleanup for {DeleteIssueCount} delete issue(s).", + deleteIssues.Count); + + foreach (ContentIntegrityIssue issue in deleteIssues) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!targetIndex.TryGetValue(issue.TargetId, out ContentIntegrityTarget? target)) + { + throw new InvalidDataException("The cleanup report references an unknown integrity target."); + } + + string path = ContentIntegrityPath.ResolveRelativePath(target.RootDirectory, issue.RelativePath); + string? parentDirectory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(parentDirectory)) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + parentDirectory, + "Integrity cleanup paths"); + } + + DeleteEntry(path, issue, target); + } + + var changedTargetIds = deleteIssues + .Select(issue => issue.TargetId) + .ToHashSet(StringComparer.Ordinal); + foreach (ContentIntegrityTarget target in targets.Where(target => + changedTargetIds.Contains(target.Id))) + { + DeleteUnexpectedEmptyDirectories(target, cancellationToken); + } + + _logger.LogInformation( + "Completed content integrity cleanup for {DeleteIssueCount} delete issue(s).", + deleteIssues.Count); + return Task.CompletedTask; + } + + private ContentIntegritySnapshotStore CreateSnapshotStore(LauncherPaths paths) + { + return new ContentIntegritySnapshotStore( + paths.IntegrityDirectory, + _atomicFileWriter, + _logger); + } + + 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(CreateManagedOrManualChangeIssue( + target, + IntegrityIssueKind.MissingFile, + expected.RelativePath, + expectedSizeBytes: expected.Size)); + continue; + } + + if (current.Size != expected.Size || + !string.Equals(current.Sha256, expected.Sha256, StringComparison.OrdinalIgnoreCase)) + { + issues.Add(CreateManagedOrManualChangeIssue( + target, + IntegrityIssueKind.ModifiedFile, + expected.RelativePath, + expectedSizeBytes: expected.Size)); + } + } + + foreach (ContentIntegrityScannedFile current in scan.Files.Values) + { + if (!expectedFiles.ContainsKey(current.RelativePath)) + { + issues.Add(CreateManagedOrManualChangeIssue( + target, + IntegrityIssueKind.UnexpectedFile, + current.RelativePath)); + } + } + + HashSet expectedEmptyDirectories = + new(snapshot.EmptyDirectories, StringComparer.OrdinalIgnoreCase); + foreach (string directory in scan.EmptyDirectories) + { + if (!expectedEmptyDirectories.Contains(directory)) + { + issues.Add(CreateManagedOrManualChangeIssue( + target, + IntegrityIssueKind.EmptyDirectory, + directory)); + } + } + + AddScanSafetyIssues(target, scan, issues); + } + + private static void AddScanSafetyIssues( + ContentIntegrityTarget target, + ContentIntegrityScanResult scan, + List issues) + { + foreach (string unsafeLink in scan.UnsafeLinks) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.UnsafeLink, + target.SourceKind.IsManagedRemote() + ? IntegrityIssueAction.Delete + : IntegrityIssueAction.Block, + unsafeLink)); + } + + foreach (ContentIntegrityScanError error in scan.Errors) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + error.RelativePath, + error.Message)); + } + } + + 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, + LexicalPath.NormalizeRelativePath(relativePath), + message, + expectedSizeBytes); + } + + private static ContentIntegrityIssue CreateVerificationError( + ContentIntegrityTarget target, + string message) + { + return CreateIssue( + target, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + ".", + message); + } + + private static ContentIntegrityIssue CreateManagedOrManualChangeIssue( + ContentIntegrityTarget target, + IntegrityIssueKind kind, + string relativePath, + long? expectedSizeBytes = null) + { + return CreateIssue( + target, + kind, + GetManagedOrManualAction(target.SourceKind, kind), + relativePath, + expectedSizeBytes: expectedSizeBytes); + } + + private static IntegrityIssueAction GetUntrackedAction(ContentSourceKind sourceKind) + { + return sourceKind switch + { + ContentSourceKind.ManagedS3 => IntegrityIssueAction.Repair, + ContentSourceKind.ManagedSingleFile => IntegrityIssueAction.Redownload, + ContentSourceKind.Manual => IntegrityIssueAction.Absorb, + _ => IntegrityIssueAction.TrustAsManual + }; + } + + private static IntegrityIssueAction GetManagedOrManualAction( + ContentSourceKind sourceKind, + IntegrityIssueKind issueKind) + { + bool isUnexpectedEntry = issueKind is + IntegrityIssueKind.UnexpectedFile or IntegrityIssueKind.EmptyDirectory; + + return sourceKind switch + { + ContentSourceKind.Manual => IntegrityIssueAction.Absorb, + ContentSourceKind.ManagedSingleFile => isUnexpectedEntry + ? IntegrityIssueAction.Delete + : IntegrityIssueAction.Redownload, + ContentSourceKind.ManagedS3 => isUnexpectedEntry + ? IntegrityIssueAction.Delete + : IntegrityIssueAction.Repair, + _ => IntegrityIssueAction.TrustAsManual + }; + } + + /// + /// Deletes one confirmed managed-content entry without following links. + /// + private void DeleteEntry( + string path, + ContentIntegrityIssue issue, + ContentIntegrityTarget target) + { + FileAttributes attributes; + try + { + attributes = File.GetAttributes(path); + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + return; + } + + if ((attributes & FileAttributes.Directory) != 0) + { + Directory.Delete(path, false); + } + else + { + File.Delete(path); + } + + _logger.LogDebug( + "Deleted confirmed unexpected integrity entry {RelativePath} from {TargetName}.", + issue.RelativePath, + target.DisplayName); + } + + private 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 = ContentIntegrityPath.GetRelativePath(target.RootDirectory, directory); + if (ContentIntegrityPath.IsIgnored(target, relativePath) || + FileSystemPathSafety.IsReparsePoint(directory) || + Directory.EnumerateFileSystemEntries(directory).Any()) + { + continue; + } + + Directory.Delete(directory); + _logger.LogDebug( + "Deleted empty integrity cleanup directory {RelativePath} from {TargetName}.", + relativePath, + target.DisplayName); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityPath.cs b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityPath.cs new file mode 100644 index 00000000..b415c2a3 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityPath.cs @@ -0,0 +1,44 @@ +using System.IO; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Infrastructure.Integrity.Support; + +/// +/// Applies integrity-target containment and ignore policy to canonical lexical paths. +/// +internal static class ContentIntegrityPath +{ + /// + /// Gets a normalized relative path after proving that it does not leave the verified target. + /// + public static string GetRelativePath(string root, string path) + { + string relativePath = LexicalPath.GetRelativePath(root, path); + if (LexicalPath.RelativePathLeavesRoot(relativePath)) + { + throw new InvalidDataException("A scanned entry resolved outside the verified target."); + } + + return relativePath; + } + + /// + /// Resolves an integrity issue path after proving that it remains in the verified target. + /// + public static string ResolveRelativePath(string root, string relativePath) + { + return LexicalPath.ResolveContainedPath( + root, + relativePath, + "An integrity issue path resolved outside its target root."); + } + + /// + /// Determines whether a target-relative path belongs to preserved inactive content. + /// + public static bool IsIgnored(ContentIntegrityTarget target, string relativePath) + { + return target.IgnoredRelativePaths.Contains(LexicalPath.NormalizeRelativePath(relativePath)); + } +} diff --git a/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs new file mode 100644 index 00000000..d4980f03 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Concurrent; +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.Core.IO; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Infrastructure.Integrity.Support; + +/// +/// Scans content integrity targets without following reparse points. +/// +internal static class ContentIntegrityScanner +{ + private const int MaxConcurrentFileHashes = 4; + + /// + /// Scans one target without following reparse points. + /// + public static async Task ScanAsync( + ContentIntegrityTarget target, + CancellationToken cancellationToken) + { + Dictionary files = new(StringComparer.OrdinalIgnoreCase); + List emptyDirectories = []; + List unsafeLinks = []; + List errors = []; + List<(string Path, string RelativePath)> fileCandidates = []; + + string root = LexicalPath.NormalizeFullPath(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 = ContentIntegrityPath.GetRelativePath(root, directory); + + try + { + bool isRootDirectory = LexicalPath.AreEquivalent(directory, root); + if (!isRootDirectory && + FileSystemPathSafety.IsReparsePoint(directory)) + { + unsafeLinks.Add(directoryRelativePath); + continue; + } + + var entries = Directory.EnumerateFileSystemEntries(directory).ToList(); + if (entries.Count == 0 && + !isRootDirectory && + !ContentIntegrityPath.IsIgnored(target, directoryRelativePath)) + { + emptyDirectories.Add(directoryRelativePath); + } + + foreach (string entry in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + string relativePath = ContentIntegrityPath.GetRelativePath(root, entry); + FileAttributes attributes = File.GetAttributes(entry); + if (ContentIntegrityPath.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; + } + + fileCandidates.Add((entry, relativePath)); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + errors.Add(new ContentIntegrityScanError(directoryRelativePath, exception.Message)); + } + } + + var scannedFiles = new ConcurrentDictionary( + StringComparer.OrdinalIgnoreCase); + var scanErrors = new ConcurrentBag(); + await Parallel.ForEachAsync( + fileCandidates, + new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = Math.Min(Environment.ProcessorCount, MaxConcurrentFileHashes) + }, + async (candidate, token) => + { + try + { + ContentIntegrityScannedFile scannedFile = await ScanFileAsync( + candidate.Path, + candidate.RelativePath, + token) + .ConfigureAwait(false); + scannedFiles[candidate.RelativePath] = scannedFile; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + scanErrors.Add(new ContentIntegrityScanError(candidate.RelativePath, exception.Message)); + } + }).ConfigureAwait(false); + + foreach ((string relativePath, ContentIntegrityScannedFile scannedFile) in scannedFiles) + { + files[relativePath] = scannedFile; + } + + errors.AddRange(scanErrors); + + return new ContentIntegrityScanResult(files, emptyDirectories, unsafeLinks, errors); + } + + /// + /// Determines whether a completed scan exactly matches an 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(LexicalPath.NormalizeRelativePath) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + return normalizedExpectedPaths.SetEquals(scan.Files.Keys); + } + + private static async Task ScanFileAsync( + string filePath, + string relativePath, + CancellationToken cancellationToken) + { + await using FileStream stream = new( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 1024 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + long length = stream.Length; + byte[] hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false); + return new ContentIntegrityScannedFile(relativePath, length, Convert.ToHexString(hash)); + } +} + +internal sealed record ContentIntegrityScannedFile(string RelativePath, long Size, string Sha256); + +internal sealed record ContentIntegrityScanError(string RelativePath, string Message); + +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..50ff9ec1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegritySnapshotStore.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +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 GenLauncherGO.Core.IO; +using GenLauncherGO.Infrastructure.Persistence.Services; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Integrity.Support; + +/// +/// Persists content integrity snapshots for verified targets. +/// +internal sealed class ContentIntegritySnapshotStore +{ + private const int SnapshotSchemaVersion = 1; + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true + }; + + private readonly IAtomicFileWriter _atomicFileWriter; + + private readonly ILogger _logger; + + private readonly string _snapshotDirectory; + + public ContentIntegritySnapshotStore( + string snapshotDirectory, + IAtomicFileWriter atomicFileWriter, + ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(snapshotDirectory); + _snapshotDirectory = LexicalPath.NormalizeFullPath(snapshotDirectory); + _atomicFileWriter = atomicFileWriter ?? throw new ArgumentNullException(nameof(atomicFileWriter)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Writes a trusted snapshot for a previously completed safe scan. + /// + public async Task WriteSnapshotAsync( + ContentIntegrityTarget target, + ContentIntegrityScanResult scan, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + 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()); + + string snapshotPath = GetSnapshotPath(target.Id); + await _atomicFileWriter.WriteAsync( + snapshotPath, + (stream, token) => JsonSerializer.SerializeAsync(stream, snapshot, _jsonOptions, token), + cancellationToken) + .ConfigureAwait(false); + _logger.LogInformation( + "Captured SHA-256 integrity snapshot for {TargetName}; files: {FileCount}.", + target.DisplayName, + snapshot.Files.Count); + } + + /// + /// Reads a persisted snapshot, returning when none exists. + /// + public async Task ReadSnapshotAsync( + string targetId, + CancellationToken cancellationToken) + { + string path = GetSnapshotPath(targetId); + if (!File.Exists(path)) + { + _logger.LogDebug( + "No integrity snapshot exists for target {TargetId}.", + targetId); + 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)) + { + _logger.LogWarning( + "Integrity snapshot for target {TargetId} has unsupported schema or ownership.", + targetId); + throw new InvalidDataException("The integrity snapshot schema or ownership is not supported."); + } + + _logger.LogDebug( + "Loaded integrity snapshot for target {TargetId}; files: {FileCount}.", + targetId, + snapshot.Files.Count); + return snapshot; + } + + 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. +/// +internal sealed record ContentIntegritySnapshotFileEntry(string RelativePath, long Size, string Sha256); + +/// +/// Describes a persisted trusted content snapshot. +/// +internal sealed record ContentIntegritySnapshotDocument( + int SchemaVersion, + string TargetId, + ContentSourceKind SourceKind, + IReadOnlyList Files, + IReadOnlyList EmptyDirectories); diff --git a/GenLauncherGO.Infrastructure/Launching/Contracts/ILaunchContentIntegrityTargetBuilder.cs b/GenLauncherGO.Infrastructure/Launching/Contracts/ILaunchContentIntegrityTargetBuilder.cs new file mode 100644 index 00000000..c490c913 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Contracts/ILaunchContentIntegrityTargetBuilder.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Launching.Models; + +namespace GenLauncherGO.Infrastructure.Launching.Contracts; + +internal interface ILaunchContentIntegrityTargetBuilder +{ + IReadOnlyList BuildTargets( + LaunchContentIntegrityTargetRequest request); +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/DeploymentFileTransaction.cs b/GenLauncherGO.Infrastructure/Launching/Services/DeploymentFileTransaction.cs new file mode 100644 index 00000000..47df13a3 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/DeploymentFileTransaction.cs @@ -0,0 +1,734 @@ +using System; +using System.Buffers; +using System.IO; +using System.Security.Cryptography; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Owns verified backup, staging, deployment, and restoration for individual game-directory files. +/// +internal sealed class DeploymentFileTransaction +{ + private const int FileBufferSize = 1024 * 128; + + private readonly IHardLinkCreator _hardLinkCreator; + private readonly ILogger _logger; + + public DeploymentFileTransaction(IHardLinkCreator hardLinkCreator, ILogger logger) + { + _hardLinkCreator = hardLinkCreator ?? throw new ArgumentNullException(nameof(hardLinkCreator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Journals intent and completion around committing a verified launcher-owned backup before removing a target. + /// + public DeploymentBackupDocument BackupTargetFile( + DeploymentStatePaths deploymentPaths, + FileStream journal, + string deploymentId, + string targetRelativePath, + string targetPath) + { + string backupRelativePath = CreateBackupRelativePath(deploymentId, targetRelativePath); + string backupPath = DeploymentPathResolver.ResolveDeploymentStatePath( + deploymentPaths.DeploymentDirectory, + backupRelativePath); + backupPath = FileSystemPathSafety.ResolveOwnedSubpath( + deploymentPaths.DeploymentDirectory, + backupPath, + "Deployment backup paths", + "the deployment directory"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath) ?? deploymentPaths.BackupDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + Path.GetDirectoryName(backupPath) ?? deploymentPaths.BackupDirectory, + "Deployment backup paths"); + bool canMoveOriginal = _hardLinkCreator.ArePathsOnSameVolume(targetPath, backupPath); + string backupStagingPath = canMoveOriginal + ? string.Empty + : backupPath + $".partial-{Guid.NewGuid():N}"; + string backupStagingRelativePath = canMoveOriginal + ? string.Empty + : DeploymentPathResolver.ToRelativeManifestPath( + deploymentPaths.DeploymentDirectory, + backupStagingPath); + DeploymentStateStore.AppendJournalDurably( + journal, + DeploymentJournalRecord.FileBackupStarted( + targetRelativePath, + backupRelativePath, + backupStagingRelativePath)); + + DeploymentFileFingerprint backupFingerprint; + try + { + if (canMoveOriginal) + { + File.Move(targetPath, backupPath, false); + backupFingerprint = ComputeFileFingerprint(backupPath); + } + else + { + backupFingerprint = CopyFileWithMetadataAndFlush(targetPath, backupStagingPath); + File.Move(backupStagingPath, backupPath, false); + } + } + catch + { + if (!string.IsNullOrWhiteSpace(backupStagingPath)) + { + DeleteFileClearingReadOnly(backupStagingPath); + } + + throw; + } + + var backedUpRecord = DeploymentJournalRecord.FileBackedUp( + targetRelativePath, + backupRelativePath, + backupFingerprint, + backupStagingRelativePath); + if (canMoveOriginal) + { + DeploymentStateStore.AppendJournal(journal, backedUpRecord); + } + else + { + // The completed backup becomes the write-ahead intent for deleting the original on another volume. + DeploymentStateStore.AppendJournalDurably(journal, backedUpRecord); + } + + if (!canMoveOriginal) + { + EnsureFingerprintMatches( + targetPath, + backupFingerprint, + "The original game file changed while its backup was being committed."); + DeleteFileClearingReadOnly(targetPath); + } + + return new DeploymentBackupDocument( + backupRelativePath, + backupFingerprint, + backupStagingRelativePath); + } + + /// + /// Deploys one file with a hard link first and verified copy fallback. + /// + public (DeploymentMethod Method, DeploymentFileFingerprint Fingerprint, string? FileIdentity) DeployFile( + FileStream source, + string sourcePath, + string targetPath, + string stagingPath, + DeploymentFileFingerprint expectedFingerprint) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + sourcePath, + "Deployment source paths"); + + if (File.Exists(targetPath)) + { + throw new IOException("A deployment target appeared after its original content was backed up."); + } + + DeploymentMethod method; + string? fileIdentity = null; + try + { + bool sourceIsReadOnly = (File.GetAttributes(sourcePath) & FileAttributes.ReadOnly) != 0; + if (!sourceIsReadOnly && + _hardLinkCreator.ArePathsOnSameVolume(sourcePath, stagingPath) && + _hardLinkCreator.TryCreateHardLink(stagingPath, sourcePath)) + { + method = DeploymentMethod.HardLink; + fileIdentity = DeploymentStateStore.GetFileIdentity(sourcePath); + if (!string.Equals( + fileIdentity, + DeploymentStateStore.GetFileIdentity(stagingPath), + StringComparison.Ordinal)) + { + throw new IOException("The staged hard link did not reference its source file."); + } + } + else + { + if (File.Exists(stagingPath)) + { + throw new IOException("A deployment staging path was occupied unexpectedly."); + } + + DeploymentFileFingerprint copiedFingerprint = CopyFileAndFlush(source, stagingPath); + File.SetLastWriteTimeUtc(stagingPath, File.GetLastWriteTimeUtc(sourcePath)); + if (copiedFingerprint != expectedFingerprint) + { + throw new IOException("The source file changed while it was staged for deployment."); + } + + method = DeploymentMethod.Copy; + _logger.LogDebug( + "Hard-link deployment was unavailable for {FileName}; used a verified file copy.", + Path.GetFileName(targetPath)); + } + + File.Move(stagingPath, targetPath, false); + return (method, expectedFingerprint, fileIdentity); + } + catch + { + DeleteOwnedStagingFileIfExpected(stagingPath, expectedFingerprint, false); + throw; + } + } + + /// + /// Replays one persisted file transaction to remove deployed content or restore its original backup. + /// + public void RestoreFile( + LauncherPaths paths, + DeploymentStatePaths deploymentPaths, + DeploymentManifestDocument manifest, + DeploymentFileDocument file, + FileStream journal) + { + string targetPath = DeploymentPathResolver.ResolveGamePath(paths, file.TargetRelativePath); + EnsureSafeGameMutationPath(paths, targetPath); + CleanupGameStagingFile( + paths, + file.StagingRelativePath, + file.DeployedFingerprint, + false); + CleanupGameStagingFile( + paths, + file.RestoreStagingRelativePath, + file.BackupFingerprint, + true); + CleanupBackupStagingFile(deploymentPaths, file.BackupStagingRelativePath); + + if (string.IsNullOrWhiteSpace(file.BackupRelativePath)) + { + DeleteDeployedFileWithoutBackup(file, targetPath, journal); + return; + } + + string backupPath = DeploymentPathResolver.ResolveDeploymentStatePath( + deploymentPaths.DeploymentDirectory, + file.BackupRelativePath); + backupPath = FileSystemPathSafety.ResolveOwnedSubpath( + deploymentPaths.DeploymentDirectory, + backupPath, + "Deployment backup paths", + "the deployment directory"); + (DeploymentFileFingerprint Fingerprint, bool Exists) backup = + ResolveBackupFingerprint(file, backupPath, targetPath); + if (!backup.Exists) + { + return; + } + + RestoreBackup( + paths, + manifest.DeploymentId, + file, + targetPath, + backupPath, + backup.Fingerprint, + journal); + } + + public static DeploymentFileFingerprint ComputeFileFingerprint(FileStream stream) + { + stream.Position = 0; + return new DeploymentFileFingerprint(stream.Length, Convert.ToHexString(SHA256.HashData(stream))); + } + + public static FileStream OpenDeploymentSource(string path) + { + return new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + FileBufferSize, + FileOptions.SequentialScan); + } + + /// + /// Verifies that a game-directory mutation target stays in the game folder and does not cross child reparse points. + /// + public static void EnsureSafeGameMutationPath(LauncherPaths paths, string targetPath) + { + _ = FileSystemPathSafety.ResolveOwnedSubpath( + paths.GameDirectory, + targetPath, + "Deployment target paths", + "the game directory"); + } + + private void DeleteDeployedFileWithoutBackup( + DeploymentFileDocument file, + string targetPath, + FileStream journal) + { + if (!File.Exists(targetPath)) + { + return; + } + + RequireExpectedTarget( + targetPath, + file, + "A deployed game file was modified after launch preparation; cleanup left it untouched."); + DeleteDeployedTarget(targetPath, file); + DeploymentStateStore.AppendJournal( + journal, + DeploymentJournalRecord.FileCleanupDeleted(file.TargetRelativePath)); + } + + private static (DeploymentFileFingerprint Fingerprint, bool Exists) ResolveBackupFingerprint( + DeploymentFileDocument file, + string backupPath, + string targetPath) + { + DeploymentFileFingerprint? backupFingerprint = file.BackupFingerprint; + bool backupExists = File.Exists(backupPath); + if (backupExists) + { + DeploymentFileFingerprint observedBackupFingerprint = ComputeFileFingerprint(backupPath); + if (backupFingerprint is not null && observedBackupFingerprint != backupFingerprint) + { + throw new InvalidDataException( + "A launcher-owned deployment backup changed unexpectedly; the game file was left untouched."); + } + + backupFingerprint = observedBackupFingerprint; + } + + if (backupFingerprint is null) + { + throw new InvalidDataException( + "Deployment recovery cannot verify the original game file because its backup fingerprint is missing."); + } + + if (!backupExists && + (!File.Exists(targetPath) || ComputeFileFingerprint(targetPath) != backupFingerprint)) + { + throw new InvalidDataException( + "The original game-file backup is missing and the target is not already restored."); + } + + return (backupFingerprint, backupExists); + } + + private void RestoreBackup( + LauncherPaths paths, + string deploymentId, + DeploymentFileDocument file, + string targetPath, + string backupPath, + DeploymentFileFingerprint backupFingerprint, + FileStream journal) + { + EnsureSafeGameMutationPath(paths, targetPath); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? paths.GameDirectory); + EnsureSafeGameMutationPath(paths, targetPath); + string candidateRestoreStagingPath = CreateSiblingStagingPath( + targetPath, + deploymentId, + "restore"); + bool canMoveOriginal = _hardLinkCreator.ArePathsOnSameVolume( + backupPath, + candidateRestoreStagingPath); + string restoreStagingPath = canMoveOriginal + ? string.Empty + : candidateRestoreStagingPath; + string restoreStagingRelativePath = canMoveOriginal + ? string.Empty + : DeploymentPathResolver.ToRelativeManifestPath( + paths.GameDirectory, + restoreStagingPath); + DeploymentStateStore.AppendJournalDurably( + journal, + DeploymentJournalRecord.FileCleanupRestoreStarted( + file.TargetRelativePath, + file.BackupRelativePath!, + restoreStagingRelativePath)); + + try + { + if (File.Exists(targetPath)) + { + RequireExpectedRestoreTarget( + targetPath, + file, + backupFingerprint, + "A game file was modified after launch preparation; its original backup was preserved."); + DeleteDeployedTarget(targetPath, file); + } + + if (canMoveOriginal) + { + File.Move(backupPath, targetPath, false); + } + else + { + StageVerifiedFile( + backupPath, + restoreStagingPath, + backupFingerprint); + File.Move(restoreStagingPath, targetPath, false); + DeleteFileClearingReadOnly(backupPath); + } + + DeploymentStateStore.AppendJournal( + journal, + DeploymentJournalRecord.FileCleanupRestored( + file.TargetRelativePath, + file.BackupRelativePath!)); + } + catch + { + if (!string.IsNullOrWhiteSpace(restoreStagingPath)) + { + DeleteOwnedStagingFileIfExpected( + restoreStagingPath, + backupFingerprint, + true); + } + + throw; + } + } + + private static string CreateBackupRelativePath(string deploymentId, string targetRelativePath) + { + return LexicalPath.NormalizeRelativePath(Path.Combine( + DeploymentStateStore.BackupsDirectoryName, + deploymentId, + targetRelativePath)); + } + + private static void StageVerifiedFile( + string sourcePath, + string stagingPath, + DeploymentFileFingerprint expectedFingerprint) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + sourcePath, + "Deployment source paths"); + + if (File.Exists(stagingPath)) + { + throw new IOException("A deployment staging path was occupied unexpectedly."); + } + + DeploymentFileFingerprint copiedFingerprint = CopyFileWithMetadataAndFlush(sourcePath, stagingPath); + if (copiedFingerprint != expectedFingerprint) + { + throw new IOException("The source file changed while it was copied into the game directory."); + } + } + + private static DeploymentFileFingerprint CopyFileAndFlush(FileStream source, string destinationPath) + { + source.Position = 0; + byte[] buffer = ArrayPool.Shared.Rent(FileBufferSize); + try + { + using FileStream destination = new( + destinationPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + FileBufferSize, + FileOptions.SequentialScan); + int bytesRead; + while ((bytesRead = source.Read(buffer, 0, buffer.Length)) != 0) + { + destination.Write(buffer, 0, bytesRead); + } + + destination.Flush(true); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + return ComputeFileFingerprint(destinationPath); + } + + /// + /// Uses the Windows file-copy path so alternate streams and security data are retained, then restores + /// timestamps and mutable attributes that the platform copy operation does not preserve exactly. + /// + private static DeploymentFileFingerprint CopyFileWithMetadataAndFlush( + string sourcePath, + string destinationPath) + { + FileAttributes sourceAttributes = File.GetAttributes(sourcePath); + DateTime creationTimeUtc = File.GetCreationTimeUtc(sourcePath); + DateTime lastAccessTimeUtc = File.GetLastAccessTimeUtc(sourcePath); + DateTime lastWriteTimeUtc = File.GetLastWriteTimeUtc(sourcePath); + + try + { + File.Copy(sourcePath, destinationPath, false); + FileAttributes destinationAttributes = File.GetAttributes(destinationPath); + if ((destinationAttributes & FileAttributes.ReadOnly) != 0) + { + File.SetAttributes(destinationPath, destinationAttributes & ~FileAttributes.ReadOnly); + } + + using (FileStream destination = new( + destinationPath, + FileMode.Open, + FileAccess.ReadWrite, + FileShare.Read)) + { + destination.Flush(true); + } + + DeploymentFileFingerprint fingerprint = ComputeFileFingerprint(destinationPath); + + File.SetCreationTimeUtc(destinationPath, creationTimeUtc); + File.SetLastAccessTimeUtc(destinationPath, lastAccessTimeUtc); + File.SetLastWriteTimeUtc(destinationPath, lastWriteTimeUtc); + + const FileAttributes MutableAttributes = + FileAttributes.ReadOnly | + FileAttributes.Hidden | + FileAttributes.System | + FileAttributes.Archive | + FileAttributes.Temporary | + FileAttributes.Offline | + FileAttributes.NotContentIndexed; + destinationAttributes = File.GetAttributes(destinationPath); + File.SetAttributes( + destinationPath, + (destinationAttributes & ~MutableAttributes) | (sourceAttributes & MutableAttributes)); + return fingerprint; + } + catch + { + DeleteFileClearingReadOnly(destinationPath); + throw; + } + } + + private static DeploymentFileFingerprint ComputeFileFingerprint(string path) + { + using FileStream stream = OpenDeploymentSource(path); + return ComputeFileFingerprint(stream); + } + + private static void EnsureFingerprintMatches( + string path, + DeploymentFileFingerprint expectedFingerprint, + string errorMessage) + { + if (ComputeFileFingerprint(path) != expectedFingerprint) + { + throw new InvalidDataException(errorMessage); + } + } + + private static void RequireExpectedTarget( + string targetPath, + DeploymentFileDocument file, + string conflictMessage) + { + if (IsExpectedHardLink(targetPath, file)) + { + return; + } + + bool hasRecordedHardLinkIdentity = file.Method == DeploymentMethod.HardLink && + !string.IsNullOrWhiteSpace(file.DeployedFileIdentity); + if (hasRecordedHardLinkIdentity || + file.DeployedFingerprint is null || + ComputeFileFingerprint(targetPath) != file.DeployedFingerprint) + { + throw new InvalidDataException(conflictMessage); + } + } + + private static void RequireExpectedRestoreTarget( + string targetPath, + DeploymentFileDocument file, + DeploymentFileFingerprint backupFingerprint, + string conflictMessage) + { + if (IsExpectedHardLink(targetPath, file)) + { + return; + } + + DeploymentFileFingerprint targetFingerprint = ComputeFileFingerprint(targetPath); + if (targetFingerprint == backupFingerprint) + { + return; + } + + bool hasRecordedHardLinkIdentity = file.Method == DeploymentMethod.HardLink && + !string.IsNullOrWhiteSpace(file.DeployedFileIdentity); + if (hasRecordedHardLinkIdentity || + file.DeployedFingerprint is null || + targetFingerprint != file.DeployedFingerprint) + { + throw new InvalidDataException(conflictMessage); + } + } + + private static bool IsExpectedHardLink(string targetPath, DeploymentFileDocument file) + { + return file.Method == DeploymentMethod.HardLink && + !string.IsNullOrWhiteSpace(file.DeployedFileIdentity) && + string.Equals( + DeploymentStateStore.GetFileIdentity(targetPath), + file.DeployedFileIdentity, + StringComparison.Ordinal); + } + + private static void DeleteFileClearingReadOnly(string path) + { + if (!File.Exists(path)) + { + return; + } + + FileAttributes attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReadOnly) != 0) + { + File.SetAttributes(path, attributes & ~FileAttributes.ReadOnly); + } + + File.Delete(path); + } + + private static void DeleteDeployedTarget(string targetPath, DeploymentFileDocument file) + { + if (file.Method == DeploymentMethod.HardLink) + { + File.Delete(targetPath); + return; + } + + DeleteFileClearingReadOnly(targetPath); + } + + private void CleanupGameStagingFile( + LauncherPaths paths, + string? stagingRelativePath, + DeploymentFileFingerprint? expectedFingerprint, + bool clearReadOnly) + { + if (string.IsNullOrWhiteSpace(stagingRelativePath)) + { + return; + } + + string stagingPath = DeploymentPathResolver.ResolveGamePath(paths, stagingRelativePath); + EnsureSafeGameMutationPath(paths, stagingPath); + if (!File.Exists(stagingPath)) + { + return; + } + + if (expectedFingerprint is not null && + ComputeFileFingerprint(stagingPath) != expectedFingerprint) + { + _logger.LogWarning( + "Removed incomplete transaction staging file {FileName} during deployment recovery.", + Path.GetFileName(stagingPath)); + } + + if (clearReadOnly) + { + DeleteFileClearingReadOnly(stagingPath); + } + else + { + File.Delete(stagingPath); + } + } + + private static void CleanupBackupStagingFile( + DeploymentStatePaths deploymentPaths, + string? stagingRelativePath) + { + if (string.IsNullOrWhiteSpace(stagingRelativePath)) + { + return; + } + + string stagingPath = DeploymentPathResolver.ResolveDeploymentStatePath( + deploymentPaths.DeploymentDirectory, + stagingRelativePath); + stagingPath = FileSystemPathSafety.ResolveOwnedSubpath( + deploymentPaths.DeploymentDirectory, + stagingPath, + "Deployment backup staging paths", + "the deployment directory"); + if (File.Exists(stagingPath)) + { + DeleteFileClearingReadOnly(stagingPath); + } + } + + private void DeleteOwnedStagingFileIfExpected( + string stagingPath, + DeploymentFileFingerprint expectedFingerprint, + bool clearReadOnly) + { + if (!File.Exists(stagingPath)) + { + return; + } + + try + { + if (ComputeFileFingerprint(stagingPath) == expectedFingerprint) + { + if (clearReadOnly) + { + DeleteFileClearingReadOnly(stagingPath); + } + else + { + File.Delete(stagingPath); + } + + return; + } + + _logger.LogWarning( + "Left deployment staging file {FileName} untouched because its contents changed unexpectedly.", + Path.GetFileName(stagingPath)); + } + catch (IOException exception) + { + _logger.LogWarning( + exception, + "Could not inspect deployment staging file {FileName}.", + Path.GetFileName(stagingPath)); + } + } + + public static string CreateSiblingStagingPath(string targetPath, string deploymentId, string operation) + { + string directory = Path.GetDirectoryName(targetPath) + ?? throw new InvalidOperationException( + "Deployment target paths must have a parent directory."); + string fileName = Path.GetFileName(targetPath); + return Path.Combine( + directory, + $".{fileName}.GenLauncherGO-{operation}-{deploymentId}-{Guid.NewGuid():N}.tmp"); + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs b/GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs new file mode 100644 index 00000000..43f8e418 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Mods.Services; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Support; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Orchestrates launch preparation by translating selected content into deployment packages. +/// +internal sealed class DeploymentLaunchPreparationService : ILaunchPreparationService +{ + /// + /// The base game script files that must be hidden while a modded game launch is deployed. + /// + private static readonly IReadOnlyList _baseGameScriptRelativePaths = + [ + "Data/Scripts/MultiplayerScripts.scb", + "Data/Scripts/SkirmishScripts.scb", + "Data/Scripts/Scripts.ini" + ]; + + private readonly FileSystemDeploymentService _deploymentEngine; + + public DeploymentLaunchPreparationService(FileSystemDeploymentService deploymentEngine) + { + _deploymentEngine = deploymentEngine ?? throw new ArgumentNullException(nameof(deploymentEngine)); + } + + public bool Prepare( + LaunchPreparationRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + IReadOnlyList packages = CreateDeploymentPackages(request); + IReadOnlyList disabledTargetRelativePaths = request.DisableBaseGameScriptFiles + ? _baseGameScriptRelativePaths + : Array.Empty(); + DeploymentResult result = _deploymentEngine.Prepare( + request.Paths, + packages, + disabledTargetRelativePaths, + cancellationToken); + return result.Succeeded; + } + + public bool Cleanup( + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + + DeploymentResult result = _deploymentEngine.Cleanup(paths, cancellationToken); + return result.Succeeded; + } + + public bool Recover( + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + + DeploymentResult result = _deploymentEngine.Recover(paths, cancellationToken); + return result.Succeeded; + } + + private static IReadOnlyList CreateDeploymentPackages(LaunchPreparationRequest request) + { + return request.Versions + .Select((version, index) => CreateDeploymentPackage(request, version, index)) + .ToList(); + } + + private static DeploymentPackage CreateDeploymentPackage( + LaunchPreparationRequest request, + LauncherContentVersion version, + int index) + { + ArgumentNullException.ThrowIfNull(version); + + string packageRoot = LauncherContentPathResolver.ResolveVersionPath( + request.Paths, + version.ContentKey)?.FullPath + ?? string.Empty; + return new DeploymentPackage(packageRoot, index); + } + +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs new file mode 100644 index 00000000..28356a58 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs @@ -0,0 +1,567 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.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. +/// +internal sealed class FileSystemDeploymentService +{ + private readonly DeploymentFileTransaction _fileTransaction; + + private readonly ILogger _logger; + + private readonly DeploymentStateStore _stateStore; + + public FileSystemDeploymentService( + IHardLinkCreator hardLinkCreator, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(hardLinkCreator); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _fileTransaction = new DeploymentFileTransaction(hardLinkCreator, logger); + _stateStore = new DeploymentStateStore(_logger); + } + + /// + /// Prepares the game directory by deploying the selected packages. + /// + public DeploymentResult Prepare( + LauncherPaths paths, + IReadOnlyList packages, + IReadOnlyList disabledTargetRelativePaths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(packages); + ArgumentNullException.ThrowIfNull(disabledTargetRelativePaths); + + IReadOnlyList normalizedDisabledTargetRelativePaths = disabledTargetRelativePaths + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(path => LexicalPath.NormalizeRelativePath(path.Trim())) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return ExecuteLocked( + paths, + () => PrepareWithLock( + paths, + packages, + normalizedDisabledTargetRelativePaths, + cancellationToken), + DeploymentFailureKind.FileSystem, + exception => _logger.LogError( + exception, + "Deployment preparation failed before deployment recovery could run.")); + } + + /// + /// Cleans the active deployment from the game directory. + /// + public DeploymentResult Cleanup(LauncherPaths paths, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + + return ExecuteLocked( + paths, + () => RestoreCore(paths, RestoreOperationKind.Cleanup, cancellationToken), + DeploymentFailureKind.FileSystem, + exception => _logger.LogError(exception, "Deployment cleanup failed.")); + } + + /// + /// Recovers interrupted deployment work from the persisted manifest or journal. + /// + public DeploymentResult Recover(LauncherPaths paths, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + + return ExecuteLocked( + paths, + () => RestoreCore(paths, RestoreOperationKind.Recovery, cancellationToken), + DeploymentFailureKind.Manifest, + exception => _logger.LogError(exception, "Deployment recovery failed.")); + } + + /// + /// Prepares a deployment while the deployment operation lock is held. + /// + private DeploymentResult PrepareWithLock( + LauncherPaths launcherPaths, + IReadOnlyList packages, + IReadOnlyList disabledTargetRelativePaths, + CancellationToken cancellationToken) + { + List sourceStreams = []; + long preparationStartedTimestamp = Stopwatch.GetTimestamp(); + try + { + DeploymentResult cleanupResult = RestoreCore( + launcherPaths, + RestoreOperationKind.Cleanup, + cancellationToken); + if (!cleanupResult.Succeeded) + { + return cleanupResult; + } + + string deploymentId = Guid.NewGuid().ToString("N"); + DeploymentStatePaths paths = DeploymentStateStore.CreatePaths(launcherPaths, deploymentId); + OwnedDirectoryTree.EnsureExists(launcherPaths.OwnedGameDataDirectory, paths.DeploymentDirectory); + OwnedDirectoryTree.EnsureExists(paths.DeploymentDirectory, paths.BackupDirectory); + if (File.Exists(paths.JournalPath)) + { + File.Delete(paths.JournalPath); + } + + using FileStream journal = DeploymentStateStore.OpenJournal(paths.JournalPath); + string gameRoot = PhysicalDirectoryPath.ResolveExisting(launcherPaths.GameDirectory); + string gameRootIdentity = DeploymentStateStore.GetGameRootIdentity(gameRoot); + DeploymentStateStore.AppendJournalDurably( + journal, + DeploymentJournalRecord.DeploymentStarted( + deploymentId, + gameRoot, + gameRootIdentity, + launcherPaths.Game)); + + IReadOnlyList files = + DeploymentFilePlanner.ResolveDeploymentFiles(packages); + sourceStreams.AddRange(files.Select(file => DeploymentFileTransaction.OpenDeploymentSource(file.SourcePath))); + var sourceFingerprints = new DeploymentFileFingerprint[files.Count]; + Parallel.For( + 0, + files.Count, + new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = Math.Min(Environment.ProcessorCount, 4) + }, + index => + sourceFingerprints[index] = DeploymentFileTransaction.ComputeFileFingerprint(sourceStreams[index])); + (List Entries, HashSet CreatedDirectories) deployment = + ApplyDeploymentFiles( + launcherPaths, + disabledTargetRelativePaths, + paths, + journal, + deploymentId, + files, + sourceStreams, + sourceFingerprints, + cancellationToken); + + DeploymentManifestDocument document = new( + DeploymentStateStore.CurrentSchemaVersion, + deploymentId, + deployment.Entries, + deployment.CreatedDirectories.OrderByDescending(path => path.Length).ToList(), + gameRoot, + gameRootIdentity, + launcherPaths.Game); + cancellationToken.ThrowIfCancellationRequested(); + DeploymentStateStore.FlushJournal(journal); + DeploymentStateStore.WriteManifest(paths.ActiveManifestPath, document); + _logger.LogInformation( + "Prepared deployment {DeploymentId} with {FileCount} file(s) in {ElapsedMilliseconds} ms.", + deploymentId, + deployment.Entries.Count, + (long)Stopwatch.GetElapsedTime(preparationStartedTimestamp).TotalMilliseconds); + return DeploymentResult.Success(); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Deployment preparation was canceled; recovering any partial game-folder mutation."); + RestoreCore(launcherPaths, RestoreOperationKind.Recovery, CancellationToken.None); + throw; + } + catch (Exception exception) + { + _logger.LogError(exception, "Deployment preparation failed."); + DeploymentFailure prepareFailure = new( + DeploymentFailureKind.FileSystem, + launcherPaths.GameDirectory, + exception.Message); + + DeploymentResult recoveryResult; + try + { + recoveryResult = RestoreCore( + launcherPaths, + RestoreOperationKind.Recovery, + CancellationToken.None); + } + catch (Exception recoveryException) + { + _logger.LogError(recoveryException, "Deployment recovery failed after preparation failure."); + recoveryResult = DeploymentResult.Failure( + new[] + { + new DeploymentFailure( + DeploymentFailureKind.Manifest, + launcherPaths.GameDirectory, + recoveryException.Message) + }); + } + + if (recoveryResult.Succeeded) + { + return DeploymentResult.Failure(new[] { prepareFailure }); + } + + return DeploymentResult.Failure( + new[] { prepareFailure }.Concat(recoveryResult.Failures).ToArray()); + } + finally + { + foreach (FileStream sourceStream in sourceStreams) + { + sourceStream.Dispose(); + } + } + } + + /// + /// Applies every planned file mutation and returns the state that must be committed to the active manifest. + /// + private (List Entries, HashSet CreatedDirectories) ApplyDeploymentFiles( + LauncherPaths launcherPaths, + IReadOnlyList disabledTargetRelativePaths, + DeploymentStatePaths paths, + FileStream journal, + string deploymentId, + IReadOnlyList files, + IReadOnlyList sourceStreams, + IReadOnlyList sourceFingerprints, + CancellationToken cancellationToken) + { + var createdDirectories = new HashSet(StringComparer.OrdinalIgnoreCase); + var entries = new List(); + var backedUpTargetPaths = + new Dictionary(StringComparer.OrdinalIgnoreCase); + var deployedTargetPaths = files + .Select(file => file.TargetRelativePath) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + BackupDisabledTargets( + launcherPaths, + disabledTargetRelativePaths, + paths, + journal, + deploymentId, + deployedTargetPaths, + backedUpTargetPaths, + entries, + cancellationToken); + + for (int fileIndex = 0; fileIndex < files.Count; fileIndex++) + { + cancellationToken.ThrowIfCancellationRequested(); + ResolvedDeploymentFile file = files[fileIndex]; + string targetPath = DeploymentPathResolver.ResolveGamePath(launcherPaths, file.TargetRelativePath); + EnsureTargetDirectory(launcherPaths, targetPath, journal, createdDirectories); + + DeploymentFileTransaction.EnsureSafeGameMutationPath(launcherPaths, targetPath); + if (!backedUpTargetPaths.TryGetValue(file.TargetRelativePath, out DeploymentBackupDocument? backup) && + File.Exists(targetPath)) + { + backup = _fileTransaction.BackupTargetFile( + paths, + journal, + deploymentId, + file.TargetRelativePath, + targetPath); + backedUpTargetPaths[file.TargetRelativePath] = backup; + } + + FileStream source = sourceStreams[fileIndex]; + DeploymentFileFingerprint sourceFingerprint = sourceFingerprints[fileIndex]; + string stagingPath = DeploymentFileTransaction.CreateSiblingStagingPath( + targetPath, + deploymentId, + "deploy"); + string stagingRelativePath = DeploymentPathResolver.ToRelativeManifestPath( + launcherPaths.GameDirectory, + stagingPath); + DeploymentStateStore.AppendJournalDurably(journal, DeploymentJournalRecord.FileDeploymentStarted( + file.TargetRelativePath, + backup?.RelativePath, + sourceFingerprint, + backup?.Fingerprint, + stagingRelativePath)); + + DeploymentFileTransaction.EnsureSafeGameMutationPath(launcherPaths, targetPath); + (DeploymentMethod Method, DeploymentFileFingerprint Fingerprint, string? FileIdentity) deployedFile = + _fileTransaction.DeployFile( + source, + file.SourcePath, + targetPath, + stagingPath, + sourceFingerprint); + DeploymentStateStore.AppendJournal(journal, DeploymentJournalRecord.FileDeployed( + file.TargetRelativePath, + deployedFile.Method, + backup?.RelativePath, + deployedFile.Fingerprint, + backup?.Fingerprint, + stagingRelativePath, + deployedFile.FileIdentity)); + cancellationToken.ThrowIfCancellationRequested(); + + entries.Add(new DeploymentFileDocument( + file.TargetRelativePath, + deployedFile.Method, + backup?.RelativePath, + deployedFile.Fingerprint, + backup?.Fingerprint, + stagingRelativePath, + backup?.StagingRelativePath, + DeployedFileIdentity: deployedFile.FileIdentity)); + } + + return (entries, createdDirectories); + } + + private static void EnsureTargetDirectory( + LauncherPaths launcherPaths, + string targetPath, + FileStream journal, + HashSet createdDirectories) + { + DeploymentFileTransaction.EnsureSafeGameMutationPath(launcherPaths, targetPath); + string targetDirectory = Path.GetDirectoryName(targetPath) ?? launcherPaths.GameDirectory; + foreach (string directory in DeploymentFilePlanner.GetDirectoriesToCreate( + launcherPaths.GameDirectory, + targetDirectory)) + { + if (Directory.Exists(directory)) + { + continue; + } + + DeploymentFileTransaction.EnsureSafeGameMutationPath(launcherPaths, directory); + string relativeDirectory = DeploymentPathResolver.ToRelativeManifestPath( + launcherPaths.GameDirectory, + directory); + DeploymentStateStore.AppendJournalDurably( + journal, + DeploymentJournalRecord.DirectoryCreated(relativeDirectory)); + Directory.CreateDirectory(directory); + DeploymentFileTransaction.EnsureSafeGameMutationPath(launcherPaths, directory); + createdDirectories.Add(relativeDirectory); + } + } + + private DeploymentResult ExecuteLocked( + LauncherPaths paths, + Func operation, + DeploymentFailureKind failureKind, + Action logFailure) + { + try + { + using FileStream deploymentLock = DeploymentStateStore.AcquireDeploymentLock(paths); + return operation(); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logFailure(exception); + return DeploymentResult.Failure( + new[] + { + new DeploymentFailure( + failureKind, + paths.GameDirectory, + exception.Message) + }); + } + } + + /// + /// Restores a deployment during explicit cleanup or interrupted-state recovery while the operation lock is held. + /// + private DeploymentResult RestoreCore( + LauncherPaths paths, + RestoreOperationKind operationKind, + CancellationToken cancellationToken) + { + long restoreStartedTimestamp = Stopwatch.GetTimestamp(); + DeploymentManifestDocument? manifest = RestoreActiveDeployment(paths, cancellationToken); + if (manifest is null) + { + return DeploymentResult.Success(); + } + + if (operationKind == RestoreOperationKind.Recovery) + { + _logger.LogInformation("Recovered deployment state for {DeploymentId}.", manifest.DeploymentId); + } + else + { + _logger.LogInformation( + "Cleaned deployment {DeploymentId} in {ElapsedMilliseconds} ms.", + manifest.DeploymentId, + (long)Stopwatch.GetElapsedTime(restoreStartedTimestamp).TotalMilliseconds); + } + + return DeploymentResult.Success(); + } + + /// + /// Restores game files and removes the durable state for one active or interrupted deployment. + /// + private DeploymentManifestDocument? RestoreActiveDeployment( + LauncherPaths paths, + CancellationToken cancellationToken) + { + DeploymentStatePaths deploymentPaths = DeploymentStateStore.CreatePaths(paths, string.Empty); + DeploymentManifestDocument? manifest = _stateStore.ReadManifestOrJournal(paths, deploymentPaths); + + if (manifest is null) + { + DeploymentStateStore.DeleteDeploymentStateFiles(deploymentPaths); + return null; + } + + CleanupManifest(paths, deploymentPaths, manifest, cancellationToken); + DeleteEmptyBackupDirectories(deploymentPaths, cancellationToken); + DeploymentStateStore.DeleteDeploymentStateFiles(deploymentPaths); + return manifest; + } + + /// + /// Backs up deployment targets that should be hidden without deploying a replacement file. + /// + private void BackupDisabledTargets( + LauncherPaths launcherPaths, + IReadOnlyList disabledTargetRelativePaths, + DeploymentStatePaths deploymentPaths, + FileStream journal, + string deploymentId, + IReadOnlySet deployedTargetPaths, + Dictionary backedUpTargetPaths, + List entries, + CancellationToken cancellationToken) + { + foreach (string disabledTargetRelativePath in disabledTargetRelativePaths) + { + cancellationToken.ThrowIfCancellationRequested(); + + string targetRelativePath = DeploymentPathResolver.NormalizeManifestPath(disabledTargetRelativePath); + if (backedUpTargetPaths.ContainsKey(targetRelativePath)) + { + continue; + } + + string targetPath = DeploymentPathResolver.ResolveGamePath(launcherPaths, targetRelativePath); + DeploymentFileTransaction.EnsureSafeGameMutationPath(launcherPaths, targetPath); + if (!File.Exists(targetPath)) + { + _logger.LogDebug( + "Skipped disabling base game file {FileName} because it does not exist.", + Path.GetFileName(targetPath)); + continue; + } + + DeploymentBackupDocument backup = _fileTransaction.BackupTargetFile( + deploymentPaths, + journal, + deploymentId, + targetRelativePath, + targetPath); + backedUpTargetPaths[targetRelativePath] = backup; + + if (!deployedTargetPaths.Contains(targetRelativePath)) + { + entries.Add(new DeploymentFileDocument( + targetRelativePath, + DeploymentMethod.Copy, + backup.RelativePath, + null, + backup.Fingerprint, + null, + backup.StagingRelativePath)); + } + + _logger.LogDebug( + "Temporarily disabled base game file {FileName} for modded launch deployment.", + Path.GetFileName(targetPath)); + } + } + + /// + /// Replays persisted deployment state to remove deployed files and restore original backups. + /// + private void CleanupManifest( + LauncherPaths paths, + DeploymentStatePaths deploymentPaths, + DeploymentManifestDocument manifest, + CancellationToken cancellationToken) + { + using FileStream journal = DeploymentStateStore.OpenJournal(deploymentPaths.JournalPath); + foreach (DeploymentFileDocument file in manifest.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + _fileTransaction.RestoreFile(paths, deploymentPaths, manifest, file, journal); + } + + foreach (string relativeDirectory in manifest.CreatedDirectories.OrderByDescending(path => path.Length)) + { + cancellationToken.ThrowIfCancellationRequested(); + + string directoryPath = DeploymentPathResolver.ResolveGamePath(paths, relativeDirectory); + DeploymentFileTransaction.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)); + } + } + + private static void DeleteEmptyBackupDirectories( + DeploymentStatePaths deploymentPaths, + CancellationToken cancellationToken) + { + string backupRoot = Path.Combine( + deploymentPaths.DeploymentDirectory, + DeploymentStateStore.BackupsDirectoryName); + if (!Directory.Exists(backupRoot)) + { + return; + } + + cancellationToken.ThrowIfCancellationRequested(); + OwnedDirectoryTree.DeleteEmptyDirectories( + new OwnedContentPath( + deploymentPaths.DeploymentDirectory, + backupRoot)); + } + + private enum RestoreOperationKind + { + Cleanup, + Recovery + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs new file mode 100644 index 00000000..32ad3b70 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs @@ -0,0 +1,548 @@ +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.Core.IO; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Integrity.Contracts; +using GenLauncherGO.Infrastructure.Integrity.Support; +using GenLauncherGO.Infrastructure.Launching.Contracts; +using GenLauncherGO.Infrastructure.Mods.Support; +using GenLauncherGO.Infrastructure.Remote.Contracts; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +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. +/// +internal sealed class FileSystemLaunchContentIntegrityResolutionService : ILaunchContentIntegrityResolutionService +{ + private readonly IRemoteAssetDownloader _assetDownloader; + + private readonly ILauncherContentCatalog _catalog; + private readonly IContentIntegrityService _integrityService; + + private readonly ILogger _logger; + + private readonly ManagedPackageSourceResolver _packageSourceResolver; + + private readonly IS3PackageUpdater _s3PackageUpdater; + + private readonly ISingleFilePackageUpdater _singleFilePackageUpdater; + + private readonly ILaunchContentIntegrityTargetBuilder _targetBuilder; + + public FileSystemLaunchContentIntegrityResolutionService( + IContentIntegrityService integrityService, + ILaunchContentIntegrityTargetBuilder targetBuilder, + ManagedPackageSourceResolver packageSourceResolver, + IS3PackageUpdater s3PackageUpdater, + ISingleFilePackageUpdater singleFilePackageUpdater, + IRemoteAssetDownloader assetDownloader, + ILauncherContentCatalog catalog, + ILogger logger) + { + _integrityService = integrityService ?? throw new ArgumentNullException(nameof(integrityService)); + _targetBuilder = targetBuilder ?? throw new ArgumentNullException(nameof(targetBuilder)); + _packageSourceResolver = packageSourceResolver ?? + throw new ArgumentNullException(nameof(packageSourceResolver)); + _s3PackageUpdater = s3PackageUpdater ?? throw new ArgumentNullException(nameof(s3PackageUpdater)); + _singleFilePackageUpdater = singleFilePackageUpdater ?? + throw new ArgumentNullException(nameof(singleFilePackageUpdater)); + _assetDownloader = assetDownloader ?? throw new ArgumentNullException(nameof(assetDownloader)); + _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + _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( + request.Paths, + contexts.Select(context => context.Target).ToList(), + cancellationToken).ConfigureAwait(false); + return new LaunchContentIntegrityVerificationResult(report, contexts); + } + + public async Task InitializeUntrackedManagedCachesAsync( + LaunchContentIntegrityResolutionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + IReadOnlySet untrackedManagedTargetIds = GetUntrackedManagedTargetIds(request.Report); + var cacheContexts = request.TargetContexts + .Where(context => + context.IsCache && + untrackedManagedTargetIds.Contains(context.Target.Id)) + .ToList(); + + bool initializedAny = false; + foreach (LaunchContentIntegrityTargetContext context in cacheContexts) + { + if (!await _integrityService.CaptureSnapshotIfMatchesExpectedFileSetAsync( + request.Paths, + context.Target, + BuildExpectedRemoteCachePaths(context), + cancellationToken).ConfigureAwait(false)) + { + 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); + (IReadOnlySet TrustAsManualTargetIds, IReadOnlySet AbsorbTargetIds, IReadOnlySet RepairTargetIds, IReadOnlyList ManagedTargetIdsInReportOrder) issueIndex = IndexResolutionIssues(request.Report); + + foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context => + issueIndex.TrustAsManualTargetIds.Contains(context.Target.Id))) + { + context.Version.Installation.ContentSourceKind = ContentSourceKind.Manual; + ContentIntegrityTarget manualTarget = context.Target with + { + SourceKind = ContentSourceKind.Manual + }; + await _integrityService.CaptureSnapshotAsync(request.Paths, manualTarget, cancellationToken).ConfigureAwait(false); + } + + _catalog.SaveLauncherData(); + + foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context => + issueIndex.AbsorbTargetIds.Contains(context.Target.Id))) + { + await _integrityService.CaptureSnapshotAsync(request.Paths, context.Target, cancellationToken).ConfigureAwait(false); + } + + await _integrityService.ApplyCleanupAsync( + request.Report, + request.TargetContexts.Select(context => context.Target).ToList(), + cancellationToken).ConfigureAwait(false); + + foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context => + issueIndex.RepairTargetIds.Contains(context.Target.Id))) + { + if (context.IsCache) + { + await RefreshManagedCacheAsync(context, cancellationToken).ConfigureAwait(false); + progress?.Report(LaunchContentIntegrityResolutionProgress.Complete(context.Target.Id)); + } + else + { + await RepairManagedPackageAsync( + request.Paths, + context, + request.Report, + new TargetPackageProgress(context.Target.Id, progress), + cancellationToken).ConfigureAwait(false); + } + } + + foreach (string targetId in issueIndex.ManagedTargetIdsInReportOrder) + { + if (contextIndex.TryGetValue(targetId, out LaunchContentIntegrityTargetContext? context)) + { + await _integrityService.CaptureSnapshotAsync(request.Paths, context.Target, cancellationToken).ConfigureAwait(false); + } + } + } + + public async Task RegisterManualImportAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + request.Version.Installation.ContentSourceKind = ContentSourceKind.Manual; + _catalog.SaveLauncherData(); + + IReadOnlyList contexts = BuildSingleVersionContexts(request); + await _integrityService.CaptureSnapshotAsync( + request.Paths, + contexts.First(context => !context.IsCache).Target, + cancellationToken).ConfigureAwait(false); + + if (request.Version.ModificationType == ModificationType.Mod) + { + await _integrityService.CaptureSnapshotAsync( + request.Paths, + contexts.First(context => context.IsCache).Target, + cancellationToken).ConfigureAwait(false); + } + } + + public async Task CaptureManagedInstallSnapshotAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (!request.Version.EffectiveContentSourceKind.IsManagedRemote()) + { + return; + } + + IReadOnlyList contexts = BuildSingleVersionContexts(request); + await _integrityService.CaptureSnapshotAsync( + request.Paths, + contexts.First(context => !context.IsCache).Target, + cancellationToken).ConfigureAwait(false); + + if (request.Version.ModificationType == ModificationType.Mod) + { + LaunchContentIntegrityTargetContext cacheContext = contexts.First(context => context.IsCache); + if (!await _integrityService.CaptureSnapshotIfMatchesExpectedFileSetAsync( + request.Paths, + cacheContext.Target, + BuildExpectedRemoteCachePaths(cacheContext), + cancellationToken).ConfigureAwait(false)) + { + await RefreshManagedCacheAsync(cacheContext, cancellationToken).ConfigureAwait(false); + await _integrityService.CaptureSnapshotAsync(request.Paths, cacheContext.Target, cancellationToken).ConfigureAwait(false); + } + } + } + + public async Task CaptureManualImageSnapshotAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (request.Version.EffectiveContentSourceKind != ContentSourceKind.Manual) + { + return; + } + + await _integrityService.CaptureSnapshotAsync( + request.Paths, + BuildSingleVersionContexts(request).First(context => context.IsCache).Target, + cancellationToken).ConfigureAwait(false); + } + + private IReadOnlyList BuildSingleVersionContexts( + LaunchContentIntegrityVersionRequest request) + { + return _targetBuilder.BuildTargets( + new LaunchContentIntegrityTargetRequest( + request.Paths, + new[] { request.Version }, + request.AllVersions, + request.CacheDisplayNameSuffix)); + } + + /// + /// Repairs a managed remote package. + /// + private async Task RepairManagedPackageAsync( + LauncherPaths paths, + LaunchContentIntegrityTargetContext context, + ContentIntegrityReport report, + IProgress progress, + CancellationToken cancellationToken) + { + var installedPath = new OwnedContentPath(paths.ModsDirectory, context.Target.RootDirectory); + var packagePaths = PackageUpdatePathSet.Create( + paths, + installedPath, + installedPath); + ManagedPackageSourceResolver.PackageSource? source = await _packageSourceResolver.ResolveAsync( + context.Version, + cancellationToken).ConfigureAwait(false); + if (source is ManagedPackageSourceResolver.PackageSource.S3 s3Source) + { + IReadOnlySet hashCheckedExtensions = + S3HashValidationPolicy.CreateRepairHashCheckedExtensions(s3Source.Files); + + IReadOnlyList repairFiles = SelectS3FileRepairEntries( + report, + context.Target.Id, + s3Source.Files); + if (repairFiles.Count > 0) + { + _logger.LogInformation( + "Repairing {FileCount} S3 package file(s) in place for {ContentName}.", + repairFiles.Count, + context.Version.DisplayName); + await _s3PackageUpdater.RepairFilesAsync( + new S3PackageFileRepairRequest( + repairFiles, + s3Source.Request, + installedPath, + hashCheckedExtensions), + progress, + cancellationToken).ConfigureAwait(false); + return; + } + + _logger.LogInformation( + "Repairing S3 package {ContentName} with full package replacement.", + context.Version.DisplayName); + + await _s3PackageUpdater.UpdateAsync( + new S3PackageUpdateRequest( + s3Source.Files, + s3Source.Request, + packagePaths, + hashCheckedExtensions), + progress, + cancellationToken).ConfigureAwait(false); + return; + } + + if (source is ManagedPackageSourceResolver.PackageSource.SingleFile singleFileSource) + { + await _singleFilePackageUpdater.UpdateAsync( + singleFileSource.Metadata, + packagePaths, + progress, + cancellationToken).ConfigureAwait(false); + return; + } + + throw new InvalidOperationException("Only managed remote content can be repaired automatically."); + } + + /// + /// Selects the S3 manifest entries that correspond to file-level repair issues for one integrity target. + /// + /// The complete integrity report. + /// The target identifier to inspect. + /// The remote manifest entries. + /// + /// The manifest entries that can be repaired in place, or an empty collection when the issue set requires a full + /// package repair. + /// + private static IReadOnlyList SelectS3FileRepairEntries( + ContentIntegrityReport report, + string targetId, + IReadOnlyList files) + { + var repairIssues = report.Issues + .Where(issue => + string.Equals(issue.TargetId, targetId, StringComparison.Ordinal) && + issue.Action is IntegrityIssueAction.Repair or IntegrityIssueAction.Redownload) + .ToList(); + if (repairIssues.Count == 0 || + repairIssues.Any(issue => + issue.Action != IntegrityIssueAction.Repair || + issue.Kind is not (IntegrityIssueKind.MissingFile or IntegrityIssueKind.ModifiedFile))) + { + return Array.Empty(); + } + + var remainingIssuePaths = repairIssues + .Select(issue => LexicalPath.NormalizeRelativePath(issue.RelativePath)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + List selectedFiles = []; + foreach (RemoteFileManifestEntry file in files) + { + string manifestRelativePath = ManifestPathResolver.NormalizeForManifestIndex(file.FileName); + string installedRelativePath = + ManifestPathResolver.NormalizeInstalledPathForManifestIndex(file.FileName); + bool matchesIssue = remainingIssuePaths.Remove(manifestRelativePath); + matchesIssue |= remainingIssuePaths.Remove(installedRelativePath); + if (matchesIssue) + { + selectedFiles.Add(file); + } + } + + return remainingIssuePaths.Count == 0 + ? selectedFiles + : Array.Empty(); + } + + private static IReadOnlySet GetUntrackedManagedTargetIds(ContentIntegrityReport report) + { + HashSet untrackedManagedTargetIds = new(StringComparer.Ordinal); + HashSet targetIdsWithOtherIssues = new(StringComparer.Ordinal); + foreach (ContentIntegrityIssue issue in report.Issues) + { + if (issue.Kind != IntegrityIssueKind.Untracked) + { + targetIdsWithOtherIssues.Add(issue.TargetId); + } + else if (issue.SourceKind.IsManagedRemote()) + { + untrackedManagedTargetIds.Add(issue.TargetId); + } + } + + untrackedManagedTargetIds.ExceptWith(targetIdsWithOtherIssues); + return untrackedManagedTargetIds; + } + + private static ( + IReadOnlySet TrustAsManualTargetIds, + IReadOnlySet AbsorbTargetIds, + IReadOnlySet RepairTargetIds, + IReadOnlyList ManagedTargetIdsInReportOrder) IndexResolutionIssues(ContentIntegrityReport report) + { + HashSet trustAsManualTargetIds = new(StringComparer.Ordinal); + HashSet absorbTargetIds = new(StringComparer.Ordinal); + HashSet repairTargetIds = new(StringComparer.Ordinal); + HashSet seenManagedTargetIds = new(StringComparer.Ordinal); + List managedTargetIds = []; + foreach (ContentIntegrityIssue issue in report.Issues) + { + switch (issue.Action) + { + case IntegrityIssueAction.TrustAsManual: + trustAsManualTargetIds.Add(issue.TargetId); + break; + case IntegrityIssueAction.Absorb: + absorbTargetIds.Add(issue.TargetId); + break; + case IntegrityIssueAction.Repair: + case IntegrityIssueAction.Redownload: + repairTargetIds.Add(issue.TargetId); + break; + } + + if (issue.SourceKind.IsManagedRemote() && seenManagedTargetIds.Add(issue.TargetId)) + { + managedTargetIds.Add(issue.TargetId); + } + } + + return (trustAsManualTargetIds, absorbTargetIds, repairTargetIds, managedTargetIds); + } + + /// + /// Refreshes a managed launcher-owned cache target from remote asset links. + /// + private async Task RefreshManagedCacheAsync( + LaunchContentIntegrityTargetContext context, + CancellationToken cancellationToken) + { + ContentIntegrityTarget target = context.Target; + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + target.RootDirectory, + "Content metadata paths"); + Directory.CreateDirectory(target.RootDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + target.RootDirectory, + "Content metadata paths"); + + IReadOnlyList<(Uri SourceUri, string DestinationPath)> assets = + ModificationImageCachePath.ResolveRemoteAssets(context.Version, target.RootDirectory); + foreach (string filePath in EnumerateFilesWithoutLinks(target.RootDirectory).ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + string relativePath = ContentIntegrityPath.GetRelativePath(target.RootDirectory, filePath); + if (ContentIntegrityPath.IsIgnored(target, 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 ((Uri sourceUri, string destinationPath) in assets) + { + await _assetDownloader.DownloadIfMissingAsync( + sourceUri, + destinationPath, + cancellationToken).ConfigureAwait(false); + } + } + + private static HashSet BuildExpectedRemoteCachePaths(LaunchContentIntegrityTargetContext context) + { + return ModificationImageCachePath.ResolveRemoteAssets(context.Version, context.Target.RootDirectory) + .Select(asset => ContentIntegrityPath.GetRelativePath( + context.Target.RootDirectory, + asset.DestinationPath)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Enumerates files without following linked directories into paths the launcher does not own. + /// + private static IEnumerable EnumerateFilesWithoutLinks(string rootDirectory) + { + return Directory.EnumerateFiles( + rootDirectory, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions()); + } + + /// + /// Enumerates directories without following linked directories into paths the launcher does not own. + /// + 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 + { + private readonly IProgress? _progress; + private readonly string _targetId; + + public TargetPackageProgress( + string targetId, + IProgress? progress) + { + ArgumentException.ThrowIfNullOrWhiteSpace(targetId); + + _targetId = targetId; + _progress = progress; + } + + public void Report(PackageUpdateProgress value) + { + _progress?.Report(LaunchContentIntegrityResolutionProgress.Package(_targetId, value)); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs new file mode 100644 index 00000000..1d4f80da --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Mods.Services; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Launching.Contracts; +using GenLauncherGO.Infrastructure.Mods.Support; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Builds launch-readiness integrity targets from launcher-owned file-system paths. +/// +internal sealed class FileSystemLaunchContentIntegrityTargetBuilder : ILaunchContentIntegrityTargetBuilder +{ + private static readonly HashSet _emptyIgnoredPaths = new(StringComparer.OrdinalIgnoreCase); + + private readonly ILogger _logger; + + public FileSystemLaunchContentIntegrityTargetBuilder( + ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + public IReadOnlyList BuildTargets( + LaunchContentIntegrityTargetRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var contexts = new List(); + foreach (LauncherContentVersion version in request.ActiveVersions) + { + contexts.Add(new LaunchContentIntegrityTargetContext( + CreatePackageTarget(request, version), + version, + false)); + + if (version.ModificationType == ModificationType.Mod) + { + contexts.Add(new LaunchContentIntegrityTargetContext( + CreateCacheTarget(request, version), + version, + true)); + } + } + + if (contexts.Count > 0) + { + _logger.LogDebug( + "Built {TargetCount} launch content integrity target(s) for {VersionCount} active version(s).", + contexts.Count, + request.ActiveVersions.Count); + } + else + { + _logger.LogDebug( + "Skipped launch content integrity target construction because no active versions were selected."); + } + + return contexts; + } + + private static ContentIntegrityTarget CreatePackageTarget( + LaunchContentIntegrityTargetRequest request, + LauncherContentVersion version) + { + OwnedContentPath packagePath = LauncherContentPathResolver.ResolveVersionPath( + request.Paths, + version.ContentKey) + ?? throw new InvalidDataException( + "Content metadata did not resolve to a supported launcher content path."); + string packageDirectory = FileSystemPathSafety.ResolveOwnedSubpath( + packagePath.OwnerRoot, + packagePath.FullPath, + "Content metadata paths", + "a launcher-owned directory"); + + return new ContentIntegrityTarget( + CreateTargetId("package", version.ContentKey), + version.DisplayName, + packageDirectory, + version.EffectiveContentSourceKind, + _emptyIgnoredPaths); + } + + private ContentIntegrityTarget CreateCacheTarget( + LaunchContentIntegrityTargetRequest request, + LauncherContentVersion version) + { + string cacheDirectory = ModificationImageCachePath.ResolveDirectory( + request.Paths, + version.ModificationType, + version.Name); + HashSet ignoredPaths = BuildCacheIgnoredPaths(request, version, cacheDirectory); + + return new ContentIntegrityTarget( + CreateTargetId("cache", version.ContentKey), + version.DisplayName + " " + request.CacheDisplayNameSuffix, + cacheDirectory, + version.EffectiveContentSourceKind, + ignoredPaths); + } + + /// + /// Builds ignored cache paths for inactive versions and the active version's locally serialized palette. + /// + private HashSet BuildCacheIgnoredPaths( + LaunchContentIntegrityTargetRequest request, + LauncherContentVersion version, + string cacheDirectory) + { + var ignoredPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!Directory.Exists(cacheDirectory)) + { + _logger.LogDebug( + "Skipped inactive image-cache ignore discovery for {ContentName} {ContentVersion} because the cache directory does not exist.", + version.Name, + version.Version); + return ignoredPaths; + } + + if (FileSystemPathSafety.IsReparsePoint(cacheDirectory)) + { + _logger.LogWarning( + "Skipped inactive image-cache ignore discovery for {ContentName} {ContentVersion} because the cache directory is a reparse point.", + version.Name, + version.Version); + return ignoredPaths; + } + + var ignoredBaseNames = request.AllVersions + .Where(candidate => + candidate.ContentKey != version.ContentKey && + candidate.ContentKey.HasName(version.Name)) + .SelectMany(ModificationImageCachePath.GetOwnedBaseNames) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (version.Theme != null) + { + ignoredBaseNames.Add(LauncherContentTheme.ResolveCacheBaseName(version.Version)); + } + + foreach (string filePath in Directory.EnumerateFiles( + cacheDirectory, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions())) + { + string relativePath = LexicalPath.GetRelativePath(cacheDirectory, filePath); + if (ignoredBaseNames.Contains(Path.GetFileNameWithoutExtension(filePath))) + { + ignoredPaths.Add(relativePath); + } + } + + if (ignoredPaths.Count > 0) + { + _logger.LogDebug( + "Ignored {IgnoredPathCount} local or inactive image-cache file(s) while building integrity target for {ContentName} {ContentVersion}.", + ignoredPaths.Count, + version.Name, + version.Version); + } + + return ignoredPaths; + } + + /// + /// Creates a stable target identifier. + /// + private static string CreateTargetId(string prefix, LauncherContentKey contentKey) + { + return string.Concat(prefix, ":", contentKey.ToStableString()); + } + +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs new file mode 100644 index 00000000..f225c627 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Discovers Windows game and World Builder executables through file-system probes. +/// +internal sealed class WindowsGameExecutableDiscoveryService : IGameExecutableDiscoveryService +{ + private readonly ILogger _logger; + private readonly LauncherRuntimePathContext _runtimePathContext; + + public WindowsGameExecutableDiscoveryService( + LauncherRuntimePathContext runtimePathContext, + ILogger logger) + { + _runtimePathContext = runtimePathContext ?? throw new ArgumentNullException(nameof(runtimePathContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public IReadOnlyList GetGameClients() + { + LauncherPaths paths = _runtimePathContext.ActivePaths; + return Discover( + LauncherFileSystemLayout.GetBuiltInGameExecutableNames(paths.Game), + paths); + } + + public IReadOnlyList GetWorldBuilders() + { + LauncherPaths paths = _runtimePathContext.ActivePaths; + return Discover( + LauncherFileSystemLayout.GetBuiltInWorldBuilderExecutableNames(paths.Game), + paths); + } + + public bool IsExecutableAvailable(string? executableName) + { + if (string.IsNullOrWhiteSpace(executableName)) + { + return false; + } + + LauncherPaths paths = _runtimePathContext.ActivePaths; + return IsExecutableAvailable(executableName, paths); + } + + /// + /// Probes one executable against an immutable active-path snapshot. + /// + private bool IsExecutableAvailable( + string executableName, + LauncherPaths paths) + { + try + { + string normalizedName = LauncherFileSystemLayout.NormalizeExecutableFileName(executableName); + string executablePath = Path.Combine(paths.GameDirectory, normalizedName); + return File.Exists(executablePath) && !FileSystemPathSafety.IsReparsePoint(executablePath); + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + _logger.LogWarning( + exception, + "Could not inspect executable availability for {ExecutableName}.", + Path.GetFileName(executableName)); + return false; + } + } + + private IReadOnlyList Discover( + IReadOnlyList executableNames, + LauncherPaths paths) + { + var executables = new List(executableNames.Count); + foreach (string executableName in executableNames) + { + executables.Add(new BuiltInExecutable( + executableName, + IsExecutableAvailable(executableName, paths))); + } + + return executables; + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs new file mode 100644 index 00000000..179f51c7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Infrastructure.Common; +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. +/// +internal sealed class WindowsGameProcessLauncher : IGameProcessLauncher +{ + /// + /// The observed game-process running time required to treat a launch as successful. + /// + private const int SuccessfulLaunchThresholdMilliseconds = 12000; + + private readonly ILogger _logger; + + private readonly IProcessFamilyLauncher _processFamilyLauncher; + + public WindowsGameProcessLauncher( + IProcessFamilyLauncher processFamilyLauncher, + ILogger logger) + { + _processFamilyLauncher = + processFamilyLauncher ?? throw new ArgumentNullException(nameof(processFamilyLauncher)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task StartAsync( + GameLaunchRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + string executablePath = Path.Combine(request.GameDirectory, request.ExecutableName); + EnsureExecutableCanLaunch(executablePath); + IProcessFamilyLaunchOperation operation = await _processFamilyLauncher.StartAsync( + executablePath, + request.Arguments, + request.GameDirectory, + cancellationToken).ConfigureAwait(false); + return new WindowsGameProcessLaunchOperation( + request.TargetKind, + request.ExecutableName, + operation, + _logger); + } + + private static void EnsureExecutableCanLaunch(string executablePath) + { + if (!File.Exists(executablePath)) + { + throw new FileNotFoundException("The selected executable is no longer available.", executablePath); + } + + if (FileSystemPathSafety.IsReparsePoint(executablePath)) + { + throw new IOException("The selected executable must not be a symbolic link or other reparse point."); + } + } + + /// + /// Adapts an infrastructure process-family operation to the Core game-launch operation contract. + /// + private sealed class WindowsGameProcessLaunchOperation : IGameProcessLaunchOperation + { + private readonly string _executableName; + + private readonly ILogger _logger; + + private readonly IProcessFamilyLaunchOperation _processFamilyOperation; + private readonly GameLaunchTargetKind _targetKind; + + public WindowsGameProcessLaunchOperation( + GameLaunchTargetKind targetKind, + string executableName, + IProcessFamilyLaunchOperation processFamilyOperation, + ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableName); + + _targetKind = targetKind; + _executableName = executableName; + _processFamilyOperation = processFamilyOperation ?? + throw new ArgumentNullException(nameof(processFamilyOperation)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _processFamilyOperation.CurrentExecutableNameChanged += ProcessFamilyOperation_CurrentExecutableNameChanged; + Completion = CompleteAsync(); + } + + public string CurrentExecutableName => _processFamilyOperation.CurrentExecutableName; + + public event EventHandler? CurrentExecutableNameChanged; + + public Task Completion { get; } + + public void ForceClose() + { + _processFamilyOperation.ForceClose(); + } + + /// + /// Determines whether the process-family completion satisfies the launch success policy. + /// + private async Task CompleteAsync() + { + try + { + TimeSpan runningDuration = await _processFamilyOperation.Completion.ConfigureAwait(false); + if (_targetKind == GameLaunchTargetKind.GameClient && + runningDuration.TotalMilliseconds < SuccessfulLaunchThresholdMilliseconds) + { + _logger.LogWarning( + "Launch of {ExecutableName} ended after {RunningDurationMs}ms, below the success threshold.", + _executableName, + runningDuration.TotalMilliseconds); + return false; + } + + return true; + } + finally + { + _processFamilyOperation.CurrentExecutableNameChanged -= + ProcessFamilyOperation_CurrentExecutableNameChanged; + } + } + + private void ProcessFamilyOperation_CurrentExecutableNameChanged(object? sender, EventArgs e) + { + CurrentExecutableNameChanged?.Invoke(this, EventArgs.Empty); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/WindowsProcessFamilyLauncher.cs b/GenLauncherGO.Infrastructure/Launching/Services/WindowsProcessFamilyLauncher.cs new file mode 100644 index 00000000..4c0aae2e --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsProcessFamilyLauncher.cs @@ -0,0 +1,683 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +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. +/// +internal sealed class WindowsProcessFamilyLauncher : IProcessFamilyLauncher +{ + private const int ProcessPollMilliseconds = 100; + + // Some launchers exit before their replacement child becomes visible in a process snapshot. + private const int ProcessHandoffGraceMilliseconds = 500; + + private const uint Th32CsSnapprocess = 0x00000002; + + private static readonly IntPtr _invalidHandleValue = new(-1); + + private readonly ILogger _logger; + + public WindowsProcessFamilyLauncher(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task StartAsync( + string executableName, + string arguments, + string workingDirectory, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableName); + ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory); + + return Task.Run( + () => StartLaunchOperation( + executableName, + arguments ?? string.Empty, + workingDirectory, + cancellationToken), + cancellationToken); + } + + private IProcessFamilyLaunchOperation StartLaunchOperation( + string executableName, + string arguments, + string workingDirectory, + CancellationToken cancellationToken) + { + Process process = StartExecutable(executableName, arguments, workingDirectory); + var processFamily = new ProcessFamilyTracker(process.Id, executableName, _logger); + return new WindowsProcessFamilyLaunchOperation( + processFamily.CurrentExecutableName, + process, + processFamily, + _logger, + cancellationToken); + } + + private static Process StartExecutable( + string executableName, + string arguments, + string workingDirectory) + { + Process process = Process.Start(new ProcessStartInfo + { + FileName = executableName, + Arguments = arguments, + WorkingDirectory = workingDirectory, + UseShellExecute = false + }) ?? throw new InvalidOperationException($"Failed to start {Path.GetFileName(executableName)}."); + return process; + } + + /// + /// Returns when ToolHelp cannot capture a snapshot so tracking can fall back to the root + /// process. + /// + private static IReadOnlyList? TryCaptureProcessSnapshot() + { + IntPtr snapshotHandle = CreateToolhelp32Snapshot(Th32CsSnapprocess, 0); + if (snapshotHandle == _invalidHandleValue) + { + return null; + } + + try + { + var entries = new List(); + var nativeEntry = new NativeProcessEntry + { + Size = (uint)Marshal.SizeOf() + }; + if (!Process32First(snapshotHandle, ref nativeEntry)) + { + return entries; + } + + do + { + entries.Add(new ProcessSnapshotEntry( + unchecked((int)nativeEntry.ProcessId), + unchecked((int)nativeEntry.ParentProcessId), + nativeEntry.ExecutableFileName ?? string.Empty)); + } while (Process32Next(snapshotHandle, ref nativeEntry)); + + return entries; + } + finally + { + CloseHandle(snapshotHandle); + } + } + + // ToolHelp provides parent process ids that System.Diagnostics.Process does not expose reliably. + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern bool Process32First(IntPtr snapshotHandle, ref NativeProcessEntry processEntry); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern bool Process32Next(IntPtr snapshotHandle, ref NativeProcessEntry processEntry); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + private static bool IsProcessRunning(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + private static void ForceCloseProcess(int processId) + { + using var process = Process.GetProcessById(processId); + if (!process.HasExited) + { + process.Kill(true); + } + } + + /// + /// Observes a started Windows process family and exposes a force-close command for it. + /// + private sealed class WindowsProcessFamilyLaunchOperation : IProcessFamilyLaunchOperation + { + private readonly CancellationToken _cancellationToken; + + private readonly string _executableName; + private readonly ILogger _logger; + + private readonly ProcessFamilyTracker _processFamily; + + private readonly Process _rootProcess; + + private readonly Lock _syncRoot = new(); + + private bool _disposed; + + public WindowsProcessFamilyLaunchOperation( + string executableName, + Process rootProcess, + ProcessFamilyTracker processFamily, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableName); + + _executableName = executableName; + _rootProcess = rootProcess ?? throw new ArgumentNullException(nameof(rootProcess)); + _processFamily = processFamily ?? throw new ArgumentNullException(nameof(processFamily)); + _cancellationToken = cancellationToken; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + Completion = Task.Run(WaitForProcessFamilyExit); + } + + public string CurrentExecutableName => _processFamily.CurrentExecutableName; + + public event EventHandler? CurrentExecutableNameChanged; + + public Task Completion { get; } + + public void ForceClose() + { + _logger.LogInformation( + "Force close requested for launched process family {ExecutableName}.", + _executableName); + _processFamily.ForceClose(); + } + + private TimeSpan WaitForProcessFamilyExit() + { + string currentExecutableName = CurrentExecutableName; + try + { + while (_processFamily.IsRunning()) + { + RaiseCurrentExecutableNameChangedIfNeeded(ref currentExecutableName); + if (_cancellationToken.WaitHandle.WaitOne(ProcessPollMilliseconds)) + { + _cancellationToken.ThrowIfCancellationRequested(); + } + } + + RaiseCurrentExecutableNameChangedIfNeeded(ref currentExecutableName); + return _processFamily.RunningDuration; + } + finally + { + DisposeRootProcess(); + } + } + + private void RaiseCurrentExecutableNameChangedIfNeeded(ref string currentExecutableName) + { + string updatedExecutableName = CurrentExecutableName; + if (string.Equals(updatedExecutableName, currentExecutableName, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + currentExecutableName = updatedExecutableName; + CurrentExecutableNameChanged?.Invoke(this, EventArgs.Empty); + } + + private void DisposeRootProcess() + { + lock (_syncRoot) + { + if (_disposed) + { + return; + } + + _rootProcess.Dispose(); + _disposed = true; + } + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + private struct NativeProcessEntry + { + public uint Size; + + public uint UsageCount; + + public uint ProcessId; + + public IntPtr DefaultHeapId; + + public uint ModuleId; + + public uint ThreadCount; + + public uint ParentProcessId; + + public int PriorityClassBase; + + public uint Flags; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] + public string? ExecutableFileName; + } + + /// + /// Tracks descendants across launcher handoffs until the complete Windows process family exits. + /// + internal sealed class ProcessFamilyTracker + { + private readonly Func?> _captureProcessSnapshot; + + private readonly Action _forceCloseProcess; + + private readonly TimeProvider _timeProvider; + + private readonly TimeSpan _handoffGracePeriod; + + private readonly Func _isProcessRunning; + + private readonly ILogger _logger; + + private readonly int _rootProcessId; + + private readonly DateTime _startedAtUtc; + + private readonly Lock _syncRoot = new(); + + // Retain recently exited parents so a replacement child appearing in a later snapshot is still discovered. + private readonly Dictionary _trackedProcesses = []; + + private bool _childProcessObserved; + + private string _currentExecutableName; + + // An empty snapshot after a child was seen may be a handoff gap rather than the end of the family. + private DateTime? _emptyFamilyObservedAtUtc; + + private DateTime _lastObservedRunningAtUtc; + + private int _nextProcessOrder; + + private bool _snapshotFailureLogged; + + public ProcessFamilyTracker( + int rootProcessId, + string rootExecutableName, + ILogger logger) + : this( + rootProcessId, + rootExecutableName, + logger, + TryCaptureProcessSnapshot, + IsProcessRunning, + TimeProvider.System, + TimeSpan.FromMilliseconds(ProcessHandoffGraceMilliseconds), + ForceCloseProcess) + { + } + + internal ProcessFamilyTracker( + int rootProcessId, + string rootExecutableName, + ILogger logger, + Func?> captureProcessSnapshot, + Func isProcessRunning, + TimeProvider timeProvider, + TimeSpan handoffGracePeriod, + Action forceCloseProcess) + { + _rootProcessId = rootProcessId; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _captureProcessSnapshot = captureProcessSnapshot ?? + throw new ArgumentNullException(nameof(captureProcessSnapshot)); + _isProcessRunning = isProcessRunning ?? throw new ArgumentNullException(nameof(isProcessRunning)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _handoffGracePeriod = handoffGracePeriod; + _forceCloseProcess = forceCloseProcess ?? throw new ArgumentNullException(nameof(forceCloseProcess)); + _startedAtUtc = _timeProvider.GetUtcNow().UtcDateTime; + _lastObservedRunningAtUtc = _startedAtUtc; + _currentExecutableName = NormalizeExecutableName(rootExecutableName); + _trackedProcesses.Add( + rootProcessId, + new TrackedProcess(_currentExecutableName, 0, _nextProcessOrder++, _startedAtUtc)); + } + + public TimeSpan RunningDuration => _lastObservedRunningAtUtc - _startedAtUtc; + + public string CurrentExecutableName + { + get + { + lock (_syncRoot) + { + return _currentExecutableName; + } + } + } + + public bool IsRunning() + { + lock (_syncRoot) + { + return IsRunningCore(); + } + } + + public void ForceClose() + { + IReadOnlyList processIds; + lock (_syncRoot) + { + processIds = GetTrackedRunningProcessIds(); + } + + foreach (int processId in processIds) + { + TryForceCloseProcess(processId); + } + } + + private bool IsRunningCore() + { + IReadOnlyList? entries = _captureProcessSnapshot(); + if (entries == null) + { + LogSnapshotFailureOnce(); + return IsRootProcessRunning(); + } + + DateTime nowUtc = _timeProvider.GetUtcNow().UtcDateTime; + ExpireRetiredProcessIds(nowUtc); + TrackDescendants(entries, nowUtc); + + var runningProcessIds = entries + .Select(entry => entry.ProcessId) + .ToHashSet(); + + IReadOnlyList knownRunningProcessIds = UpdateKnownProcessState(runningProcessIds, nowUtc); + UpdateCurrentExecutableName(knownRunningProcessIds); + if (HasActiveTrackedProcess(knownRunningProcessIds)) + { + _emptyFamilyObservedAtUtc = null; + _lastObservedRunningAtUtc = nowUtc; + return true; + } + + if (!_childProcessObserved) + { + return false; + } + + // A process that remained visible for at least the full handoff window was the stable launched client, not + // a transient launcher. Once it exits, report completion immediately instead of adding another grace delay. + if (!ShouldWaitForHandoff(nowUtc)) + { + return false; + } + + _emptyFamilyObservedAtUtc ??= nowUtc; + return nowUtc - _emptyFamilyObservedAtUtc.Value < _handoffGracePeriod; + } + + private IReadOnlyList GetTrackedRunningProcessIds() + { + IReadOnlyList? entries = _captureProcessSnapshot(); + if (entries == null) + { + LogSnapshotFailureOnce(); + return _trackedProcesses.Keys + .Where(_isProcessRunning) + .ToList(); + } + + TrackDescendants(entries, _timeProvider.GetUtcNow().UtcDateTime); + var runningProcessIds = entries + .Select(entry => entry.ProcessId) + .ToHashSet(); + return _trackedProcesses + .Where(process => !process.Value.RetiredAtUtc.HasValue) + .Select(process => process.Key) + .Where(runningProcessIds.Contains) + .ToList(); + } + + private void TryForceCloseProcess(int processId) + { + try + { + _forceCloseProcess(processId); + _logger.LogInformation("Force closed launched process {ProcessId}.", processId); + } + catch (ArgumentException) + { + _logger.LogDebug( + "Tracked launched process {ProcessId} exited before force close completed.", + processId); + } + catch (InvalidOperationException) + { + _logger.LogDebug( + "Tracked launched process {ProcessId} exited before force close completed.", + processId); + } + catch (Win32Exception exception) + { + _logger.LogWarning(exception, "Failed to force close launched process {ProcessId}.", processId); + } + catch (NotSupportedException exception) + { + _logger.LogWarning(exception, "Failed to force close launched process {ProcessId}.", processId); + } + } + + private void ExpireRetiredProcessIds(DateTime nowUtc) + { + foreach (KeyValuePair retiredProcess in _trackedProcesses.ToList()) + { + if (retiredProcess.Value.RetiredAtUtc is not DateTime retiredAtUtc || + nowUtc - retiredAtUtc < _handoffGracePeriod) + { + continue; + } + + _trackedProcesses.Remove(retiredProcess.Key); + } + } + + private void TrackDescendants(IReadOnlyList entries, DateTime observedAtUtc) + { + bool addedProcess; + do + { + addedProcess = false; + foreach (ProcessSnapshotEntry entry in entries) + { + if (!_trackedProcesses.TryGetValue( + entry.ParentProcessId, + out TrackedProcess? parentProcess) || + _trackedProcesses.ContainsKey(entry.ProcessId)) + { + continue; + } + + addedProcess = true; + _childProcessObserved = true; + var trackedProcess = new TrackedProcess( + NormalizeExecutableName(entry.ExecutableFileName), + parentProcess.Depth + 1, + _nextProcessOrder++, + observedAtUtc); + _trackedProcesses.Add(entry.ProcessId, trackedProcess); + + _logger.LogDebug( + "Tracking launched child process {ProcessId} ({ExecutableName}) for cleanup wait.", + entry.ProcessId, + trackedProcess.ExecutableName); + } + } while (addedProcess); + } + + private bool ShouldWaitForHandoff(DateTime nowUtc) + { + TrackedProcess? lastRetiredChild = _trackedProcesses.Values + .Where(process => process.Depth > 0 && process.RetiredAtUtc.HasValue) + .OrderByDescending(process => process.Depth) + .ThenByDescending(process => process.Order) + .FirstOrDefault(); + return lastRetiredChild is not null && + nowUtc - lastRetiredChild.ObservedAtUtc < _handoffGracePeriod; + } + + private IReadOnlyList UpdateKnownProcessState( + HashSet runningProcessIds, + DateTime nowUtc) + { + var knownRunningProcessIds = new List(); + foreach ((int processId, TrackedProcess process) in _trackedProcesses) + { + if (runningProcessIds.Contains(processId) && !process.RetiredAtUtc.HasValue) + { + knownRunningProcessIds.Add(processId); + continue; + } + + if (!process.RetiredAtUtc.HasValue) + { + process.RetiredAtUtc = nowUtc; + } + } + + return knownRunningProcessIds; + } + + private bool HasActiveTrackedProcess(IReadOnlyList knownRunningProcessIds) + { + if (!_childProcessObserved) + { + return knownRunningProcessIds.Contains(_rootProcessId); + } + + return knownRunningProcessIds.Any(processId => processId != _rootProcessId); + } + + private void UpdateCurrentExecutableName(IReadOnlyList knownRunningProcessIds) + { + IEnumerable candidates = _childProcessObserved + ? knownRunningProcessIds.Where(processId => processId != _rootProcessId) + : knownRunningProcessIds; + int? currentProcessId = candidates + .OrderByDescending(GetProcessDepth) + .ThenByDescending(GetProcessOrder) + .Cast() + .FirstOrDefault(); + if (!currentProcessId.HasValue) + { + return; + } + + if (_trackedProcesses.TryGetValue(currentProcessId.Value, out TrackedProcess? process) && + !string.IsNullOrWhiteSpace(process.ExecutableName)) + { + _currentExecutableName = process.ExecutableName; + } + } + + private int GetProcessDepth(int processId) + { + return _trackedProcesses.TryGetValue(processId, out TrackedProcess? process) + ? process.Depth + : 0; + } + + private int GetProcessOrder(int processId) + { + return _trackedProcesses.TryGetValue(processId, out TrackedProcess? process) + ? process.Order + : 0; + } + + private bool IsRootProcessRunning() + { + if (!_isProcessRunning(_rootProcessId)) + { + return false; + } + + if (_trackedProcesses.TryGetValue(_rootProcessId, out TrackedProcess? process) && + !string.IsNullOrWhiteSpace(process.ExecutableName)) + { + _currentExecutableName = process.ExecutableName; + } + + _lastObservedRunningAtUtc = _timeProvider.GetUtcNow().UtcDateTime; + return true; + } + + private void LogSnapshotFailureOnce() + { + if (_snapshotFailureLogged) + { + return; + } + + _logger.LogWarning( + "Could not inspect launched child processes; falling back to the root launch process only."); + _snapshotFailureLogged = true; + } + + private static string NormalizeExecutableName(string? executableName) + { + return Path.GetFileName(executableName?.Trim() ?? string.Empty); + } + + private sealed class TrackedProcess + { + public TrackedProcess(string executableName, int depth, int order, DateTime observedAtUtc) + { + ExecutableName = executableName; + Depth = depth; + Order = order; + ObservedAtUtc = observedAtUtc; + } + + public string ExecutableName { get; } + + public int Depth { get; } + + public int Order { get; } + + public DateTime ObservedAtUtc { get; } + + public DateTime? RetiredAtUtc { get; set; } + } + } + + internal sealed record ProcessSnapshotEntry( + int ProcessId, + int ParentProcessId, + string ExecutableFileName = ""); +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs new file mode 100644 index 00000000..e13e0845 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.IO; +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 deployable package files and applies package precedence. Windows executable and library binaries remain + /// in launcher-owned package storage and are never copied into the user's game directory. + /// + public static IReadOnlyList ResolveDeploymentFiles( + IReadOnlyList packages) + { + ArgumentNullException.ThrowIfNull(packages); + + var filesByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DeploymentPackage package in packages.OrderBy(package => package.Precedence)) + { + string packageRoot = LexicalPath.NormalizeFullPath(package.RootDirectory); + if (!Directory.Exists(packageRoot)) + { + throw new DirectoryNotFoundException( + $"Deployment package directory was not found: {package.RootDirectory}"); + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + packageRoot, + "Deployment package directories"); + foreach (string sourcePath in FileSystemPathSafety.GetDirectoryFilesWithNoReparsePoints( + packageRoot, + "Deployment package directories")) + { + string extension = Path.GetExtension(sourcePath); + // Community packages can include their own launchers and tools. Treat those binaries as package-owned + // code, not game-directory payload, so deployment cannot replace executable code in the user's install. + if (string.Equals(extension, ".exe", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".dll", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string relativePath = DeploymentPathResolver.ToRelativeManifestPath(packageRoot, sourcePath); + string targetRelativePath = LexicalPath.NormalizeRelativePath( + BigFileVariantPath.GetDeploymentPath(relativePath)); + string normalizedTargetPath = DeploymentPathResolver.NormalizeManifestPath(targetRelativePath); + filesByTarget[normalizedTargetPath] = new ResolvedDeploymentFile( + sourcePath, + normalizedTargetPath); + } + } + + return filesByTarget.Values.OrderBy(file => file.TargetRelativePath, StringComparer.OrdinalIgnoreCase).ToList(); + } + + /// + /// Returns missing directories between a game root and target directory in parent-first order. + /// + public static IEnumerable GetDirectoriesToCreate(string gameRoot, string targetDirectory) + { + var directories = new Stack(); + string root = LexicalPath.NormalizeFullPath(gameRoot); + string? current = LexicalPath.NormalizeFullPath(targetDirectory); + while (!string.IsNullOrWhiteSpace(current) && + !LexicalPath.AreEquivalent(current, root) && + !Directory.Exists(current)) + { + directories.Push(current); + current = Directory.GetParent(current)?.FullName; + } + + return directories; + } +} + +internal sealed record ResolvedDeploymentFile( + string SourcePath, + string TargetRelativePath); + +internal sealed record DeploymentPackage +{ + public DeploymentPackage(string rootDirectory, int precedence) + { + ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory); + + RootDirectory = rootDirectory; + Precedence = precedence; + } + + public string RootDirectory { get; } + + public int Precedence { get; } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs new file mode 100644 index 00000000..b34ea4d9 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs @@ -0,0 +1,52 @@ +using System.IO; +using GenLauncherGO.Core.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. + /// + public static string ResolveGamePath(LauncherPaths paths, string relativePath) + { + string normalizedPath = NormalizeManifestPath(relativePath); + string gameRoot = LexicalPath.NormalizeFullPath(paths.GameDirectory); + string candidatePath = LexicalPath.ResolvePath(gameRoot, normalizedPath); + string ownedGameDataRoot = LexicalPath.NormalizeFullPath(paths.OwnedGameDataDirectory); + + if (!LexicalPath.IsPathInDirectory(candidatePath, gameRoot) || + LexicalPath.IsPathInDirectory(candidatePath, ownedGameDataRoot)) + { + throw new InvalidDataException($"Deployment target path '{relativePath}' is outside the game directory."); + } + + return candidatePath; + } + + public static string NormalizeManifestPath(string relativePath) + { + return ManifestPathResolver.NormalizeForDeploymentManifest(relativePath); + } + + public static string ToRelativeManifestPath(string rootDirectory, string path) + { + return NormalizeManifestPath(LexicalPath.GetRelativePath(rootDirectory, path)); + } + + /// + /// Resolves a deployment-state-relative path. + /// + public static string ResolveDeploymentStatePath(string deploymentDirectory, string relativePath) + { + return LexicalPath.ResolveContainedPath( + deploymentDirectory, + NormalizeManifestPath(relativePath), + $"Deployment state path '{relativePath}' is outside the deployment directory."); + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentResult.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentResult.cs new file mode 100644 index 00000000..1a841cbb --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentResult.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Carries deployment failure details to the launch-preparation boundary. +/// +internal sealed record DeploymentResult +{ + private DeploymentResult(IReadOnlyList failures) + { + Failures = failures.ToArray(); + } + + public bool Succeeded => Failures.Count == 0; + + public IReadOnlyList Failures { get; } + + public static DeploymentResult Success() + { + return new DeploymentResult(Array.Empty()); + } + + public static DeploymentResult Failure(IReadOnlyList failures) + { + ArgumentNullException.ThrowIfNull(failures); + if (failures.Count == 0) + { + throw new ArgumentException("At least one deployment failure is required.", nameof(failures)); + } + + return new DeploymentResult(failures); + } +} + +internal sealed record DeploymentFailure(DeploymentFailureKind Kind, string Path, string Message); + +internal enum DeploymentFailureKind +{ + FileSystem, + Manifest +} + +/// +/// Records whether cleanup must account for a deployed hard link or copy. +/// +internal enum DeploymentMethod +{ + HardLink, + Copy +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs new file mode 100644 index 00000000..2554485b --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs @@ -0,0 +1,730 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Persistence.Services; +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 +{ + public const string BackupsDirectoryName = "Backups"; + + internal const int CurrentSchemaVersion = 2; + + private const string ActiveManifestFileName = "active.json"; + + private const string JournalFileName = "journal.jsonl"; + + private const string LockFileName = "deployment.lock"; + + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) + { + // Schema-v2 manifests may contain retired reporting fields that are irrelevant to safe recovery. + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + WriteIndented = true + }; + + private static readonly JsonSerializerOptions _journalJsonOptions = new(JsonSerializerDefaults.Web) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip + }; + + private readonly ILogger _logger; + + public DeploymentStateStore(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + 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); + } + + /// + /// Holds an exclusive file lock so deployment preparation, cleanup, and recovery cannot overlap. + /// + public static FileStream AcquireDeploymentLock(LauncherPaths paths) + { + DeploymentStatePaths deploymentPaths = CreatePaths(paths, string.Empty); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, deploymentPaths.DeploymentDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + deploymentPaths.LockPath, + "Deployment lock paths"); + + return new FileStream( + deploymentPaths.LockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None); + } + + /// + /// Commits the completed deployment manifest with atomic replacement semantics. + /// + public static void WriteManifest(string manifestPath, DeploymentManifestDocument manifest) + { + new AtomicFileWriter().WriteText(manifestPath, JsonSerializer.Serialize(manifest, _jsonOptions)); + } + + /// + /// Deletes persisted active deployment state after cleanup or recovery. + /// + public static void DeleteDeploymentStateFiles(DeploymentStatePaths paths) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + paths.ActiveManifestPath, + "Deployment manifest paths"); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + paths.JournalPath, + "Deployment journal paths"); + + if (File.Exists(paths.ActiveManifestPath)) + { + File.Delete(paths.ActiveManifestPath); + } + + if (File.Exists(paths.JournalPath)) + { + File.Delete(paths.JournalPath); + } + } + + /// + /// Opens the append-only journal once for a deployment operation. + /// + public static FileStream OpenJournal(string journalPath) + { + string journalDirectory = Path.GetDirectoryName(journalPath) + ?? throw new InvalidOperationException( + "Deployment journal paths must have a parent directory."); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + journalDirectory, + "Deployment journal paths"); + Directory.CreateDirectory(journalDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + journalPath, + "Deployment journal paths"); + + return new FileStream( + journalPath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + 4096, + FileOptions.None); + } + + /// + /// Durably appends one standalone recovery record. + /// + public static void AppendJournal(string journalPath, DeploymentJournalRecord record) + { + using FileStream journal = OpenJournal(journalPath); + AppendJournalDurably(journal, record); + } + + /// + /// Appends a recovery record. A later durable intent flushes earlier completion records in order. + /// + public static void AppendJournal(FileStream journal, DeploymentJournalRecord record) + { + ArgumentNullException.ThrowIfNull(journal); + ArgumentNullException.ThrowIfNull(record); + + byte[] bytes = Encoding.UTF8.GetBytes( + JsonSerializer.Serialize(record, _journalJsonOptions) + Environment.NewLine); + journal.Write(bytes, 0, bytes.Length); + } + + /// + /// Appends and commits an intent before its recoverable file-system mutation runs. + /// + public static void AppendJournalDurably(FileStream journal, DeploymentJournalRecord record) + { + AppendJournal(journal, record); + FlushJournal(journal); + } + + /// + /// Commits all appended recovery records through the storage device. + /// + public static void FlushJournal(FileStream journal) + { + ArgumentNullException.ThrowIfNull(journal); + journal.Flush(true); + } + + /// + /// Reads the active manifest or reconstructs it from the deployment journal. + /// + public DeploymentManifestDocument? ReadManifestOrJournal( + LauncherPaths launcherPaths, + DeploymentStatePaths paths) + { + DeploymentManifestDocument? manifest = TryReadManifest(paths.ActiveManifestPath, out Exception? readException); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + paths.JournalPath, + "Deployment journal paths"); + if (File.Exists(paths.JournalPath)) + { + DeploymentManifestDocument? journalManifest = RebuildManifestFromJournal(paths); + if (journalManifest is not null) + { + ValidateGameRoot(launcherPaths, journalManifest); + 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); + } + + if (manifest is not null) + { + ValidateGameRoot(launcherPaths, manifest); + } + + return manifest; + } + + if (manifest is not null) + { + ValidateGameRoot(launcherPaths, manifest); + 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; + } + + /// + /// Tries to read a completed deployment manifest without preventing journal fallback. + /// + private DeploymentManifestDocument? TryReadManifest(string manifestPath, out Exception? readException) + { + readException = null; + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + manifestPath, + "Deployment manifest paths"); + 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 exception) when (exception is IOException or JsonException or NotSupportedException) + { + readException = exception; + _logger.LogWarning(exception, "Deployment manifest could not be read; journal recovery will be attempted."); + return null; + } + } + + /// + /// Rebuilds the effective deployment state from intent and completion records after an interrupted mutation. + /// + private DeploymentManifestDocument? RebuildManifestFromJournal(DeploymentStatePaths paths) + { + var filesByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + var backupsByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + var backupStartsByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + var directories = new HashSet(StringComparer.OrdinalIgnoreCase); + bool sawDeploymentStateRecord = false; + string? deploymentId = null; + string? gameRoot = null; + string? gameRootIdentity = null; + SupportedGame game = SupportedGame.Unknown; + + foreach (string line in File.ReadLines(paths.JournalPath)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + DeploymentJournalRecord? record; + try + { + record = JsonSerializer.Deserialize(line, _journalJsonOptions); + } + catch (JsonException exception) + { + _logger.LogWarning(exception, "Skipped unreadable deployment journal record."); + continue; + } + + if (record is null) + { + continue; + } + + if (string.Equals(record.Action, DeploymentJournalRecord.DeploymentStartedAction, StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + deploymentId = record.DeploymentId; + gameRoot = record.GameRoot; + gameRootIdentity = record.GameRootIdentity; + game = record.Game; + continue; + } + + if (string.IsNullOrWhiteSpace(record.TargetRelativePath)) + { + _logger.LogWarning("Skipped deployment journal record without a target path."); + continue; + } + + switch (record.Action) + { + case DeploymentJournalRecord.DirectoryCreatedAction: + sawDeploymentStateRecord = true; + directories.Add(record.TargetRelativePath); + break; + + case DeploymentJournalRecord.FileBackupStartedAction: + sawDeploymentStateRecord = true; + if (!string.IsNullOrWhiteSpace(record.BackupRelativePath)) + { + var backup = new DeploymentBackupDocument( + record.BackupRelativePath, + record.BackupFingerprint, + record.StagingRelativePath); + backupStartsByTarget[record.TargetRelativePath] = backup; + backupsByTarget[record.TargetRelativePath] = backup; + } + + break; + + case DeploymentJournalRecord.FileBackedUpAction: + { + sawDeploymentStateRecord = true; + var backup = new DeploymentBackupDocument( + record.BackupRelativePath ?? string.Empty, + record.BackupFingerprint, + record.StagingRelativePath); + backupsByTarget[record.TargetRelativePath] = backup; + filesByTarget[record.TargetRelativePath] = new DeploymentFileDocument( + record.TargetRelativePath, + DeploymentMethod.Copy, + record.BackupRelativePath, + null, + record.BackupFingerprint, + null, + record.StagingRelativePath); + break; + } + + case DeploymentJournalRecord.FileDeploymentStartedAction: + { + sawDeploymentStateRecord = true; + string targetRelativePath = record.TargetRelativePath; + backupsByTarget.TryGetValue(targetRelativePath, out DeploymentBackupDocument? backup); + filesByTarget[targetRelativePath] = new DeploymentFileDocument( + targetRelativePath, + // The hard-link attempt happens after this intent record. Treat an interrupted operation as a + // potential hard link so recovery never clears a shared ReadOnly attribute through the target. + DeploymentMethod.HardLink, + record.BackupRelativePath ?? backup?.RelativePath, + record.DeployedFingerprint, + record.BackupFingerprint ?? backup?.Fingerprint, + record.StagingRelativePath, + backup?.StagingRelativePath); + break; + } + + case DeploymentJournalRecord.FileDeployedAction: + { + sawDeploymentStateRecord = true; + string targetRelativePath = record.TargetRelativePath; + backupsByTarget.TryGetValue(targetRelativePath, out DeploymentBackupDocument? backup); + filesByTarget[targetRelativePath] = new DeploymentFileDocument( + targetRelativePath, + record.Method ?? DeploymentMethod.Copy, + record.BackupRelativePath ?? backup?.RelativePath, + record.DeployedFingerprint, + record.BackupFingerprint ?? backup?.Fingerprint, + record.StagingRelativePath, + backup?.StagingRelativePath, + DeployedFileIdentity: record.DeployedFileIdentity); + break; + } + + case DeploymentJournalRecord.FileCleanupDeleteCompletedAction: + case DeploymentJournalRecord.FileCleanupRestoredAction: + sawDeploymentStateRecord = true; + filesByTarget.Remove(record.TargetRelativePath); + break; + + case DeploymentJournalRecord.FileCleanupRestoreStartedAction: + sawDeploymentStateRecord = true; + if (filesByTarget.TryGetValue( + record.TargetRelativePath, + out DeploymentFileDocument? restoringFile)) + { + filesByTarget[record.TargetRelativePath] = restoringFile with + { + RestoreStagingRelativePath = record.StagingRelativePath + }; + } + + break; + } + } + + foreach (KeyValuePair backupStart in backupStartsByTarget) + { + if (filesByTarget.ContainsKey(backupStart.Key)) + { + continue; + } + + string backupPath = DeploymentPathResolver.ResolveDeploymentStatePath( + paths.DeploymentDirectory, + backupStart.Value.RelativePath); + if (!File.Exists(backupPath)) + { + DeleteIncompleteLauncherBackup(paths, backupStart.Value.StagingRelativePath); + continue; + } + + filesByTarget[backupStart.Key] = new DeploymentFileDocument( + backupStart.Key, + DeploymentMethod.Copy, + backupStart.Value.RelativePath, + null, + backupStart.Value.Fingerprint, + null, + backupStart.Value.StagingRelativePath); + } + + if (filesByTarget.Count == 0 && directories.Count == 0 && !sawDeploymentStateRecord) + { + return null; + } + + return new DeploymentManifestDocument( + CurrentSchemaVersion, + deploymentId ?? "recovered", + filesByTarget.Values.ToList(), + directories.OrderByDescending(path => path.Length).ToList(), + gameRoot, + gameRootIdentity, + game); + } + + private static void DeleteIncompleteLauncherBackup( + DeploymentStatePaths paths, + string? stagingRelativePath) + { + if (string.IsNullOrWhiteSpace(stagingRelativePath)) + { + return; + } + + string stagingPath = DeploymentPathResolver.ResolveDeploymentStatePath( + paths.DeploymentDirectory, + stagingRelativePath); + stagingPath = FileSystemPathSafety.ResolveOwnedSubpath( + paths.DeploymentDirectory, + stagingPath, + "Deployment backup staging paths", + "the deployment directory"); + if (File.Exists(stagingPath)) + { + FileAttributes attributes = File.GetAttributes(stagingPath); + if ((attributes & FileAttributes.ReadOnly) != 0) + { + File.SetAttributes(stagingPath, attributes & ~FileAttributes.ReadOnly); + } + + File.Delete(stagingPath); + } + } + + /// + /// Refuses to replay durable state against a different game installation. + /// + private static void ValidateGameRoot(LauncherPaths paths, DeploymentManifestDocument manifest) + { + if (manifest.SchemaVersion != CurrentSchemaVersion) + { + throw new InvalidDataException("The deployment state schema is not supported."); + } + + if (manifest.Game != paths.Game) + { + throw new InvalidDataException("Deployment state belongs to a different supported game."); + } + + if (string.IsNullOrWhiteSpace(manifest.GameRoot) || + !LexicalPath.AreEquivalent( + manifest.GameRoot, + PhysicalDirectoryPath.ResolveExisting(paths.GameDirectory))) + { + throw new InvalidDataException("Deployment state belongs to a different game directory."); + } + + if (string.IsNullOrWhiteSpace(manifest.GameRootIdentity) || + !string.Equals( + manifest.GameRootIdentity, + GetGameRootIdentity(paths.GameDirectory), + StringComparison.Ordinal)) + { + throw new InvalidDataException("The game directory changed after deployment state was recorded."); + } + } + + internal static string GetGameRootIdentity(string gameDirectory) + { + return FormatIdentity(PhysicalDirectoryPath.GetIdentity(gameDirectory)); + } + + internal static string GetFileIdentity(string filePath) + { + return FormatIdentity(PhysicalDirectoryPath.GetFileIdentity(filePath)); + } + + private static string FormatIdentity(PhysicalFileSystemIdentity identity) + { + return $"{identity.VolumeSerialNumber:X8}:{identity.FileIndex:X16}"; + } +} + +internal sealed record DeploymentStatePaths( + string DeploymentDirectory, + string ActiveManifestPath, + string JournalPath, + string LockPath, + string BackupDirectory); + +/// +/// Defines the versioned manifest persisted for deployment cleanup and recovery. +/// +internal sealed record DeploymentManifestDocument( + int SchemaVersion, + string DeploymentId, + IReadOnlyList Files, + IReadOnlyList CreatedDirectories, + string? GameRoot = null, + string? GameRootIdentity = null, + SupportedGame Game = SupportedGame.Unknown); + +/// +/// Defines one file-system mutation persisted in a deployment manifest. +/// +internal sealed record DeploymentFileDocument( + string TargetRelativePath, + DeploymentMethod Method, + string? BackupRelativePath, + DeploymentFileFingerprint? DeployedFingerprint = null, + DeploymentFileFingerprint? BackupFingerprint = null, + string? StagingRelativePath = null, + string? BackupStagingRelativePath = null, + string? RestoreStagingRelativePath = null, + string? DeployedFileIdentity = null); + +/// +/// Identifies exact file bytes that a deployment transaction may safely remove or replace. +/// +internal sealed record DeploymentFileFingerprint(long Length, string Sha256); + +internal sealed record DeploymentBackupDocument( + string RelativePath, + DeploymentFileFingerprint? Fingerprint, + string? StagingRelativePath); + +/// +/// Defines one intent or completion record in the append-only recovery journal. +/// +internal sealed record DeploymentJournalRecord( + string Action, + string? TargetRelativePath, + string? BackupRelativePath, + DeploymentMethod? Method, + string? DeploymentId = null, + string? GameRoot = null, + string? GameRootIdentity = null, + DeploymentFileFingerprint? DeployedFingerprint = null, + DeploymentFileFingerprint? BackupFingerprint = null, + string? StagingRelativePath = null, + SupportedGame Game = SupportedGame.Unknown, + string? DeployedFileIdentity = null) +{ + public const string DeploymentStartedAction = "deployment-started"; + + public const string DirectoryCreatedAction = "directory-created"; + + public const string FileBackupStartedAction = "file-backup-started"; + + public const string FileBackedUpAction = "file-backed-up"; + + public const string FileDeploymentStartedAction = "file-deployment-started"; + + public const string FileDeployedAction = "file-deployed"; + + public const string FileCleanupDeleteCompletedAction = "file-cleanup-delete-completed"; + + public const string FileCleanupRestoreStartedAction = "file-cleanup-restore-started"; + + public const string FileCleanupRestoredAction = "file-cleanup-restored"; + + public static DeploymentJournalRecord DeploymentStarted( + string deploymentId, + string gameRoot, + string gameRootIdentity, + SupportedGame game) + { + return new DeploymentJournalRecord( + DeploymentStartedAction, + null, + null, + null, + deploymentId, + gameRoot, + gameRootIdentity, + Game: game); + } + + public static DeploymentJournalRecord DirectoryCreated(string targetRelativePath) + { + return new DeploymentJournalRecord(DirectoryCreatedAction, targetRelativePath, null, null); + } + + public static DeploymentJournalRecord FileBackupStarted( + string targetRelativePath, + string backupRelativePath, + string stagingRelativePath) + { + return new DeploymentJournalRecord( + FileBackupStartedAction, + targetRelativePath, + backupRelativePath, + null, + StagingRelativePath: stagingRelativePath); + } + + public static DeploymentJournalRecord FileBackedUp( + string targetRelativePath, + string backupRelativePath, + DeploymentFileFingerprint backupFingerprint, + string stagingRelativePath) + { + return new DeploymentJournalRecord( + FileBackedUpAction, + targetRelativePath, + backupRelativePath, + null, + BackupFingerprint: backupFingerprint, + StagingRelativePath: stagingRelativePath); + } + + public static DeploymentJournalRecord FileDeploymentStarted( + string targetRelativePath, + string? backupRelativePath, + DeploymentFileFingerprint deployedFingerprint, + DeploymentFileFingerprint? backupFingerprint, + string stagingRelativePath) + { + return new DeploymentJournalRecord( + FileDeploymentStartedAction, + targetRelativePath, + backupRelativePath, + null, + DeployedFingerprint: deployedFingerprint, + BackupFingerprint: backupFingerprint, + StagingRelativePath: stagingRelativePath); + } + + public static DeploymentJournalRecord FileDeployed( + string targetRelativePath, + DeploymentMethod method, + string? backupRelativePath, + DeploymentFileFingerprint deployedFingerprint, + DeploymentFileFingerprint? backupFingerprint, + string stagingRelativePath, + string? deployedFileIdentity = null) + { + return new DeploymentJournalRecord( + FileDeployedAction, + targetRelativePath, + backupRelativePath, + method, + DeployedFingerprint: deployedFingerprint, + BackupFingerprint: backupFingerprint, + StagingRelativePath: stagingRelativePath, + DeployedFileIdentity: deployedFileIdentity); + } + + public static DeploymentJournalRecord FileCleanupDeleted(string targetRelativePath) + { + return new DeploymentJournalRecord( + FileCleanupDeleteCompletedAction, + targetRelativePath, + null, + null); + } + + public static DeploymentJournalRecord FileCleanupRestoreStarted( + string targetRelativePath, + string backupRelativePath, + string stagingRelativePath) + { + return new DeploymentJournalRecord( + FileCleanupRestoreStartedAction, + targetRelativePath, + backupRelativePath, + null, + StagingRelativePath: stagingRelativePath); + } + + public static DeploymentJournalRecord FileCleanupRestored(string targetRelativePath, string backupRelativePath) + { + return new DeploymentJournalRecord( + FileCleanupRestoredAction, + targetRelativePath, + backupRelativePath, + null); + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/IHardLinkCreator.cs b/GenLauncherGO.Infrastructure/Launching/Support/IHardLinkCreator.cs new file mode 100644 index 00000000..47fcb3a1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/IHardLinkCreator.cs @@ -0,0 +1,17 @@ +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Queries hard-link eligibility and creates hard links between installed package files and game-directory targets. +/// +internal interface IHardLinkCreator +{ + /// + /// Determines whether two paths reside on the same physical volume and can use atomic moves or hard links. + /// + bool ArePathsOnSameVolume(string firstPath, string secondPath); + + /// + /// Attempts to create a hard link. + /// + bool TryCreateHardLink(string targetPath, string sourcePath); +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLaunchOperation.cs b/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLaunchOperation.cs new file mode 100644 index 00000000..4c8d2b61 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLaunchOperation.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Represents a launched Windows process family that can be observed and force closed. +/// +internal interface IProcessFamilyLaunchOperation +{ + /// + /// Gets the executable name for the currently running tracked process. + /// + string CurrentExecutableName { get; } + + /// + /// Gets the task that completes when every tracked process in the launched process family has exited. + /// + Task Completion { get; } + + /// + /// Occurs when changes. + /// + event EventHandler? CurrentExecutableNameChanged; + + /// + /// Force closes all currently tracked running processes in the launched process family. + /// + void ForceClose(); +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLauncher.cs b/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLauncher.cs new file mode 100644 index 00000000..ca8c5163 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLauncher.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Starts a process and waits until the launched process family has exited. +/// +internal interface IProcessFamilyLauncher +{ + /// + /// Starts the executable and returns an operation that tracks the launched process family. + /// + Task StartAsync( + string executableName, + string arguments, + string workingDirectory, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/WindowsHardLinkCreator.cs b/GenLauncherGO.Infrastructure/Launching/Support/WindowsHardLinkCreator.cs new file mode 100644 index 00000000..bdde8e02 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/WindowsHardLinkCreator.cs @@ -0,0 +1,64 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Queries Windows volume identity and creates hard links through the Windows file-system API. +/// +internal sealed class WindowsHardLinkCreator : IHardLinkCreator +{ + private readonly ILogger _logger; + + public WindowsHardLinkCreator(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + public bool ArePathsOnSameVolume(string firstPath, string secondPath) + { + string firstDirectory = ResolveParentDirectory(firstPath); + string secondDirectory = ResolveParentDirectory(secondPath); + return PhysicalDirectoryPath.GetIdentity(firstDirectory).VolumeSerialNumber == + PhysicalDirectoryPath.GetIdentity(secondDirectory).VolumeSerialNumber; + } + + public bool TryCreateHardLink(string targetPath, string sourcePath) + { + bool created = CreateHardLink( + PhysicalDirectoryPath.ToExtendedLengthPath(targetPath), + PhysicalDirectoryPath.ToExtendedLengthPath(sourcePath), + 0); + if (!created) + { + int errorCode = Marshal.GetLastWin32Error(); + _logger.LogDebug( + "Failed to create hard link {TargetFileName} from {SourceFileName}. Win32 error {ErrorCode}: {ErrorMessage}", + Path.GetFileName(targetPath), + Path.GetFileName(sourcePath), + errorCode, + new Win32Exception(errorCode).Message); + } + + return created; + } + + private static string ResolveParentDirectory(string path) + { + return Directory.Exists(path) + ? path + : Path.GetDirectoryName(path) + ?? throw new InvalidOperationException("Deployment file paths must have a parent directory."); + } + + [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..a2d5fb5c --- /dev/null +++ b/GenLauncherGO.Infrastructure/Logging/LoggingServiceCollectionExtensions.cs @@ -0,0 +1,93 @@ +using System; +using System.Globalization; +using System.IO; +using GenLauncherGO.Core.IO; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Events; +using ILogger = Serilog.ILogger; + +namespace GenLauncherGO.Infrastructure.Logging; + +public static class LoggingServiceCollectionExtensions +{ + private const int RetainedLogFileCount = 14; + + private const string LogFilePrefix = "GenLauncherGO"; + + /// + /// Registers the standard GenLauncherGO logging pipeline with rolling file logs. + /// + public static IServiceCollection AddGenLauncherGoLogging( + this IServiceCollection services, + string logDirectory, + bool enableDiagnosticLogging = false) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(logDirectory); + + Directory.CreateDirectory(logDirectory); + + string logFilePath = CreateLogFilePath(logDirectory); + PruneOldLogFiles(logDirectory, logFilePath); + LogEventLevel minimumLevel = enableDiagnosticLogging + ? LogEventLevel.Debug + : LogEventLevel.Information; + ILogger logger = new LoggerConfiguration() + .MinimumLevel.Is(minimumLevel) + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.File( + new SensitiveDataRedactingTextFormatter(), + logFilePath, + shared: false) + .CreateLogger(); + + services.AddLogging(builder => + { + builder.ClearProviders(); + builder.AddSerilog(logger, true); + }); + + return services; + } + + 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; + } + + 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)); + + int retainedCount = 1; + foreach (FileInfo logFile in logFiles) + { + if (LexicalPath.AreEquivalent(logFile.FullName, activeLogFilePath)) + { + continue; + } + + if (retainedCount < RetainedLogFileCount) + { + retainedCount++; + continue; + } + + logFile.Delete(); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Logging/SensitiveDataRedactingTextFormatter.cs b/GenLauncherGO.Infrastructure/Logging/SensitiveDataRedactingTextFormatter.cs new file mode 100644 index 00000000..b50fa24f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Logging/SensitiveDataRedactingTextFormatter.cs @@ -0,0 +1,99 @@ +using System; +using System.Globalization; +using System.IO; +using System.Text.RegularExpressions; +using Serilog.Events; +using Serilog.Formatting; + +namespace GenLauncherGO.Infrastructure.Logging; + +/// +/// Formats log events while removing local paths and obvious secret-bearing URL values. +/// +internal sealed partial class SensitiveDataRedactingTextFormatter : ITextFormatter +{ + /// + /// Replaces source-file paths emitted by exception stack traces. + /// + private static readonly Regex _stackTraceSourcePathPattern = StackTraceSourcePathPattern(); + + /// + /// Replaces UNC paths before drive-letter paths so adjacent path values cannot consume the UNC introducer. + /// + private static readonly Regex _uncWindowsPathPattern = UncWindowsPathPattern(); + + /// + /// Replaces absolute drive-letter paths using either Windows path separator. + /// + private static readonly Regex _absoluteDriveWindowsPathPattern = AbsoluteDriveWindowsPathPattern(); + + /// + /// Replaces URI user-info credentials. + /// + private static readonly Regex _uriUserInfoPattern = UriUserInfoPattern(); + + /// + /// Replaces common token, key, credential, secret, signature, and password query-string values. + /// + private static readonly Regex _sensitiveQueryValuePattern = SensitiveQueryValuePattern(); + + public void Format(LogEvent logEvent, TextWriter output) + { + ArgumentNullException.ThrowIfNull(logEvent); + ArgumentNullException.ThrowIfNull(output); + + output.Write(logEvent.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture)); + output.Write(" ["); + output.Write(GetLevelAbbreviation(logEvent.Level)); + output.Write("] "); + output.WriteLine(Redact(logEvent.RenderMessage(CultureInfo.InvariantCulture))); + + if (logEvent.Exception != null) + { + output.WriteLine(Redact(logEvent.Exception.ToString())); + } + } + + private static string GetLevelAbbreviation(LogEventLevel level) + { + return level switch + { + LogEventLevel.Verbose => "VRB", + LogEventLevel.Debug => "DBG", + LogEventLevel.Information => "INF", + LogEventLevel.Warning => "WRN", + LogEventLevel.Error => "ERR", + LogEventLevel.Fatal => "FTL", + _ => level.ToString().ToUpperInvariant() + }; + } + + private static string Redact(string value) + { + string redacted = _stackTraceSourcePathPattern.Replace(value, " in [local source]:line ${line}"); + redacted = _uriUserInfoPattern.Replace(redacted, "${scheme}[redacted]@"); + redacted = _sensitiveQueryValuePattern.Replace(redacted, "${key}[redacted]"); + redacted = _uncWindowsPathPattern.Replace(redacted, "[local path]"); + return _absoluteDriveWindowsPathPattern.Replace(redacted, "[local path]"); + } + + [GeneratedRegex(@"\sin\s[A-Za-z]:\\[^\r\n]*:line\s(?\d+)", RegexOptions.Compiled)] + private static partial Regex StackTraceSourcePathPattern(); + + [GeneratedRegex(@"\\\\[^\\/\r\n:*?""<>|]+[\\/][^\\/\r\n:*?""<>|]+(?:[\\/][^\\/\r\n:*?""<>|]*)*", + RegexOptions.Compiled)] + private static partial Regex UncWindowsPathPattern(); + + [GeneratedRegex(@"(?|]+[\\/])*[^\\/\r\n:*?""<>|]*", + RegexOptions.Compiled)] + private static partial Regex AbsoluteDriveWindowsPathPattern(); + + [GeneratedRegex(@"(?i)(?\b[a-z][a-z0-9+.-]*://)[^/\s?#@]+@", RegexOptions.Compiled)] + private static partial Regex UriUserInfoPattern(); + + [GeneratedRegex( + @"(?i)(?[?&](?:access[_-]?token|api[_-]?key|credential|secret|token|session[_-]?token|" + + @"security[_-]?token|password|signature|sig|x-amz-(?:credential|signature|security-token))=)[^&\s]+", + RegexOptions.Compiled)] + private static partial Regex SensitiveQueryValuePattern(); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherContentStateStore.cs b/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherContentStateStore.cs new file mode 100644 index 00000000..cb1f2415 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherContentStateStore.cs @@ -0,0 +1,20 @@ +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Contracts; + +/// +/// Loads and saves the compact launcher content state. +/// +internal interface ILauncherContentStateStore +{ + /// + /// Loads persisted launcher content state, returning an empty state when none can be loaded. + /// + LauncherContentState Load(LauncherPaths paths); + + /// + /// Saves launcher content state. + /// + void Save(LauncherPaths paths, LauncherContentState state); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Contracts/ILocalLauncherContentService.cs b/GenLauncherGO.Infrastructure/Mods/Contracts/ILocalLauncherContentService.cs new file mode 100644 index 00000000..c9c5946f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Contracts/ILocalLauncherContentService.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Infrastructure.Mods.Contracts; + +/// +/// Provides local file-system operations for launcher-managed content. +/// +internal interface ILocalLauncherContentService +{ + /// + /// Finds installed content versions under the launcher-owned mods directory. + /// + IReadOnlyList FindInstalledVersions(LauncherPaths paths); + + /// + /// Removes empty package-recovery directories that no longer contain a durable backup. + /// + void DeleteEmptyPackageBackupDirectories(LauncherPaths paths); + + /// + /// Deletes an installed content version from the launcher-owned mods directory. + /// + void DeleteVersion( + LauncherPaths paths, + LauncherContentKey contentKey); + + /// + /// Deletes all installed content files for a content card from the launcher-owned mods directory. + /// + void DeleteContent( + LauncherPaths paths, + LauncherContentKey contentKey); + + /// + /// Deletes the launcher-owned image cache when no content card still references the same content name. + /// + void DeleteImagesIfUnused( + LauncherPaths paths, + LauncherContentKey contentKey, + LauncherData launcherData); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentEntryState.cs b/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentEntryState.cs new file mode 100644 index 00000000..1e478962 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentEntryState.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Stores local state for one launcher content card without remote manifest metadata. +/// +internal sealed class LauncherContentEntryState +{ + public ModificationType ModificationType { get; set; } + + public string Name { get; set; } = string.Empty; + + public string DependenceName { get; set; } = string.Empty; + + public bool Installed { get; set; } + + public bool IsSelected { get; set; } + + public int NumberInList { get; set; } + + /// + /// The property name is the existing on-disk YAML key and must remain compatible with saved launcher data. + /// + public List ModificationVersions { get; set; } = + []; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentState.cs b/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentState.cs new file mode 100644 index 00000000..4aa8d396 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentState.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Stores compact launcher content state that is safe to persist locally. +/// +internal sealed class LauncherContentState +{ + public List Addons { get; set; } = []; + + public List Modifications { get; set; } = []; + + public List Patches { get; set; } = []; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentVersionState.cs b/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentVersionState.cs new file mode 100644 index 00000000..d5a9b731 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentVersionState.cs @@ -0,0 +1,34 @@ +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Stores local state for one launcher content version without remote manifest metadata. +/// +internal sealed class LauncherContentVersionState +{ + public ModificationType ModificationType { get; set; } + + public string Name { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + + public string DependenceName { get; set; } = string.Empty; + + public bool Installed { get; set; } + + public bool IsSelected { get; set; } + + /// + /// Gets or sets whether partial download content was deliberately kept for resuming in a later session. + /// + public bool DownloadSuspended { get; set; } + + /// + /// Gets or sets the progress the suspended download had reached, so its bar can be restored. + /// + public double SuspendedProgressPercentage { get; set; } + + public ContentSourceKind ContentSourceKind { get; set; } = ContentSourceKind.UnknownLegacy; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogAdvertisingReference.cs b/GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogAdvertisingReference.cs new file mode 100644 index 00000000..44e09105 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogAdvertisingReference.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents one advertising entry in the legacy remote catalog document. +/// +internal sealed class LegacyCatalogAdvertisingReference +{ + public string ModName { get; set; } = string.Empty; + + public string ModLink { get; set; } = string.Empty; + + public List ImagesData { get; set; } = []; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogModificationReference.cs b/GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogModificationReference.cs new file mode 100644 index 00000000..d1ac8157 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogModificationReference.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents one modification and its child-manifest links in the legacy remote catalog document. +/// +internal sealed class LegacyCatalogModificationReference +{ + public string ModName { get; set; } = string.Empty; + + public string ModLink { get; set; } = string.Empty; + + public List ModPatches { get; set; } = []; + + public List ModAddons { get; set; } = []; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LegacyContentManifest.cs b/GenLauncherGO.Infrastructure/Mods/Models/LegacyContentManifest.cs new file mode 100644 index 00000000..a5507f94 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LegacyContentManifest.cs @@ -0,0 +1,53 @@ +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents one content manifest using the exact property names accepted from the legacy remote backend. +/// +internal sealed class LegacyContentManifest +{ + public ModificationType ModificationType { get; set; } + + public string Name { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + + public string SimpleDownloadLink { get; set; } = string.Empty; + + // ReSharper disable once InconsistentNaming + public string UIImageSourceLink { get; set; } = string.Empty; + + public string DiscordLink { get; set; } = string.Empty; + + // ReSharper disable once InconsistentNaming + public string ModDBLink { get; set; } = string.Empty; + + public string NewsLink { get; set; } = string.Empty; + + public string DependenceName { get; set; } = string.Empty; + + public string S3HostLink { get; set; } = string.Empty; + + public string S3BucketName { get; set; } = string.Empty; + + public string S3FolderName { get; set; } = string.Empty; + + public string S3HostPublicKey { get; set; } = string.Empty; + + public string S3HostSecretKey { get; set; } = string.Empty; + + public string NetworkInfo { get; set; } = string.Empty; + + public bool Deprecated { get; set; } + + public string SupportLink { get; set; } = string.Empty; + + /// + /// Gets or sets the optional palette this modification asks the launcher to wear while it is selected. + /// + public LegacyContentThemeManifest? ColorsInformation { get; set; } + + public ContentSourceKind ContentSourceKind { get; set; } = ContentSourceKind.UnknownLegacy; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LegacyContentThemeManifest.cs b/GenLauncherGO.Infrastructure/Mods/Models/LegacyContentThemeManifest.cs new file mode 100644 index 00000000..14e6f6b7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LegacyContentThemeManifest.cs @@ -0,0 +1,37 @@ +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents the per-modification palette using the exact property names accepted from the legacy remote backend. +/// +/// +/// The backend publishes this as a nested ColorsInformation mapping with PascalCase keys and no naming +/// convention applied, so these names are part of the external contract and must not be renamed. +/// +internal sealed class LegacyContentThemeManifest +{ + public string GenLauncherBorderColor { get; set; } = string.Empty; + + public string GenLauncherInactiveBorder { get; set; } = string.Empty; + + public string GenLauncherInactiveBorder2 { get; set; } = string.Empty; + + public string GenLauncherActiveColor { get; set; } = string.Empty; + + public string GenLauncherDarkFillColor { get; set; } = string.Empty; + + public string GenLauncherDarkBackGround { get; set; } = string.Empty; + + public string GenLauncherLightBackGround { get; set; } = string.Empty; + + public string GenLauncherDefaultTextColor { get; set; } = string.Empty; + + public string GenLauncherDownloadTextColor { get; set; } = string.Empty; + + public string GenLauncherListBoxSelectionColor1 { get; set; } = string.Empty; + + public string GenLauncherListBoxSelectionColor2 { get; set; } = string.Empty; + + public string GenLauncherButtonSelectionColor { get; set; } = string.Empty; + + public string GenLauncherBackgroundImageLink { get; set; } = string.Empty; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LegacyLauncherCatalogDocument.cs b/GenLauncherGO.Infrastructure/Mods/Models/LegacyLauncherCatalogDocument.cs new file mode 100644 index 00000000..922b00f8 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LegacyLauncherCatalogDocument.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +// ReSharper disable InconsistentNaming + +/// +/// Represents the legacy top-level remote launcher catalog document. +/// +/// +/// These member names are owned by the remote backend and are intentionally preserved for exact YAML binding. +/// Infrastructure maps this transport shape to before exposing catalog data. +/// +internal sealed class LegacyLauncherCatalogDocument +{ + public List AdvData { get; set; } = []; + + public List globalAddonsData { get; set; } = []; + + public List modDatas { get; set; } = []; + + public List originalGameAddons { get; set; } = []; + + public List originalGamePatches { get; set; } = []; + + public string LauncherVersion { get; set; } = string.Empty; +} + +// ReSharper restore InconsistentNaming diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteAdvertisingReference.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteAdvertisingReference.cs new file mode 100644 index 00000000..ea954693 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteAdvertisingReference.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents a normalized remote advertising manifest reference. +/// +internal sealed class RemoteAdvertisingReference +{ + public RemoteAdvertisingReference( + string name, + string manifestUrl, + IReadOnlyList imageUrls) + { + Name = name ?? string.Empty; + ManifestUrl = manifestUrl ?? string.Empty; + ImageUrls = imageUrls ?? Array.Empty(); + } + + public string Name { get; } + + public string ManifestUrl { get; } + + 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..28ca5c33 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteCatalogModificationReference.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents a normalized remote modification manifest reference. +/// +internal sealed class RemoteCatalogModificationReference +{ + 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(); + } + + public string Name { get; } + + public string ManifestUrl { get; } + + public IReadOnlyList PatchManifestUrls { get; } + + public IReadOnlyList AddonManifestUrls { get; } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteChildManifestLoadResult.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteChildManifestLoadResult.cs new file mode 100644 index 00000000..51b0e63f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteChildManifestLoadResult.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Describes a child-manifest load that may contain partial remote results. +/// +internal sealed class RemoteChildManifestLoadResult +{ + public RemoteChildManifestLoadResult( + IReadOnlyList contentVersions, + int failedCount) + { + ContentVersions = contentVersions ?? Array.Empty(); + FailedCount = failedCount; + } + + public IReadOnlyList ContentVersions { get; } + + public int FailedCount { get; } + + public bool Succeeded => FailedCount == 0; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteLauncherCatalog.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteLauncherCatalog.cs new file mode 100644 index 00000000..be9caa31 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteLauncherCatalog.cs @@ -0,0 +1,36 @@ +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 +{ + public RemoteLauncherCatalog( + IReadOnlyList advertisingEntries, + IReadOnlyList modifications, + IReadOnlyList originalGameAddonManifestUrls, + IReadOnlyList originalGamePatchManifestUrls) + { + AdvertisingEntries = advertisingEntries ?? Array.Empty(); + Modifications = modifications ?? Array.Empty(); + OriginalGameAddonManifestUrls = originalGameAddonManifestUrls ?? Array.Empty(); + OriginalGamePatchManifestUrls = originalGamePatchManifestUrls ?? Array.Empty(); + } + + public static RemoteLauncherCatalog Empty { get; } = new( + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty()); + + public IReadOnlyList AdvertisingEntries { get; } + + public IReadOnlyList Modifications { get; } + + public IReadOnlyList OriginalGameAddonManifestUrls { get; } + + public IReadOnlyList OriginalGamePatchManifestUrls { get; } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteModificationManifest.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteModificationManifest.cs new file mode 100644 index 00000000..cf7da8f3 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteModificationManifest.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents a normalized remote modification manifest with its child manifest references. +/// +internal sealed class RemoteModificationManifest +{ + public RemoteModificationManifest( + LauncherContentVersion content, + IReadOnlyList patchManifestUrls, + IReadOnlyList addonManifestUrls) + { + Content = content ?? throw new ArgumentNullException(nameof(content)); + PatchManifestUrls = patchManifestUrls ?? Array.Empty(); + AddonManifestUrls = addonManifestUrls ?? Array.Empty(); + } + + public LauncherContentVersion Content { get; } + + public IReadOnlyList PatchManifestUrls { get; } + + 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..fd753e2f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemLocalLauncherContentService.cs @@ -0,0 +1,352 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Mods.Services; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Performs local file-system operations for launcher-managed mods, patches, add-ons, and cached images. +/// +internal sealed class FileSystemLocalLauncherContentService : ILocalLauncherContentService +{ + private readonly ILogger _logger; + + public FileSystemLocalLauncherContentService(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public IReadOnlyList FindInstalledVersions(LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + var versions = new List(); + var modsDirectory = new DirectoryInfo(paths.ModsDirectory); + if (!modsDirectory.Exists) + { + return versions; + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + paths.ModsDirectory, + "Launcher content paths"); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + paths.ModsDirectory, + "Launcher content paths"); + + foreach (DirectoryInfo contentDirectory in modsDirectory.GetDirectories()) + { + AddInstalledVersions(contentDirectory, versions); + } + + return versions; + } + + public void DeleteEmptyPackageBackupDirectories(LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + if (OwnedDirectoryTree.DeleteEmptyDirectories(paths.PackageBackupsDirectory)) + { + _logger.LogDebug("Deleted the empty launcher package recovery directory."); + } + } + + public void DeleteVersion( + LauncherPaths paths, + LauncherContentKey contentKey) + { + DeleteResolvedContent( + paths, + contentKey, + LauncherContentPathResolver.ResolveVersionPath, + "Deleted launcher content version"); + } + + public void DeleteContent( + LauncherPaths paths, + LauncherContentKey contentKey) + { + DeleteResolvedContent( + paths, + contentKey, + LauncherContentPathResolver.ResolveContentPath, + "Deleted launcher content"); + } + + /// + /// Deletes resolved launcher content together with its resumable staging and durable recovery state. + /// + private void DeleteResolvedContent( + LauncherPaths paths, + LauncherContentKey contentKey, + Func resolvePath, + string deletionDescription) + { + ArgumentNullException.ThrowIfNull(paths); + + OwnedContentPath? resolvedPath; + try + { + resolvedPath = resolvePath(paths, contentKey); + } + catch (ArgumentException exception) + { + throw new InvalidOperationException( + "Refusing to delete a launcher content path outside the mods root.", + exception); + } + + if (resolvedPath is null) + { + return; + } + + bool deletedContent = DeleteDirectoryIfExists( + resolvedPath, + deletionDescription, + contentKey.Name, + contentKey.Version); + + DeletePackageStagingDirectory(paths, resolvedPath, contentKey); + DeletePackageBackupDirectory(paths, resolvedPath, contentKey); + + if (!deletedContent) + { + return; + } + + OwnedContentPath? cleanupRoot = LauncherContentPathResolver.ResolveCleanupRootPath(paths, contentKey); + if (cleanupRoot is not null) + { + OwnedDirectoryTree.DeleteEmptyDirectories(cleanupRoot); + } + } + + public void DeleteImagesIfUnused( + LauncherPaths paths, + LauncherContentKey contentKey, + LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(launcherData); + + if (string.IsNullOrWhiteSpace(contentKey.Name) || + ContentCardExists(launcherData, contentKey.Name)) + { + return; + } + + string imageFolderPath = ModificationImageCachePath.GetDirectoryPath( + paths, + contentKey.ContentType, + contentKey.Name); + if (!Directory.Exists(imageFolderPath)) + { + return; + } + + var ownedImagePath = new OwnedContentPath(paths.ImagesDirectory, imageFolderPath); + bool removedLinkedCache = FileSystemPathSafety.IsReparsePoint(imageFolderPath); + if (!OwnedDirectoryTree.DeleteIfExists(ownedImagePath)) + { + return; + } + + if (removedLinkedCache) + { + _logger.LogWarning( + "Removed linked modification image cache folder {ImageFolderName} without traversing its target.", + Path.GetFileName(imageFolderPath)); + return; + } + + _logger.LogDebug( + "Deleted unused modification image cache folder {ImageFolderName}.", + Path.GetFileName(imageFolderPath)); + } + + private static void AddInstalledVersions( + DirectoryInfo contentDirectory, + List versions) + { + foreach (DirectoryInfo subDirectory in contentDirectory.GetDirectories()) + { + ModificationType? childContentType = null; + if (string.Equals( + subDirectory.Name, + LauncherFileSystemLayout.AddonsFolderName, + StringComparison.OrdinalIgnoreCase)) + { + childContentType = ModificationType.Addon; + } + else if (string.Equals( + subDirectory.Name, + LauncherFileSystemLayout.PatchesFolderName, + StringComparison.OrdinalIgnoreCase)) + { + childContentType = ModificationType.Patch; + } + + if (childContentType.HasValue) + { + foreach (DirectoryInfo childDirectory in subDirectory.GetDirectories()) + { + AddInstalledChildVersions( + childDirectory, + contentDirectory.Name, + childContentType.Value, + versions); + } + + continue; + } + + if (IsInstallVersionDirectory(subDirectory)) + { + versions.Add(new LauncherContentVersion(new LauncherContentInstallation + { + Installed = true + }) + { + ModificationType = ModificationType.Mod, + Name = contentDirectory.Name, + Version = subDirectory.Name + }); + } + } + } + + private static void AddInstalledChildVersions( + DirectoryInfo contentDirectory, + string parentContentName, + ModificationType contentType, + List versions) + { + foreach (DirectoryInfo versionDirectory in contentDirectory.GetDirectories()) + { + if (!IsInstallVersionDirectory(versionDirectory)) + { + continue; + } + + versions.Add(new LauncherContentVersion(new LauncherContentInstallation + { + Installed = true + }) + { + ModificationType = contentType, + Name = contentDirectory.Name, + Version = versionDirectory.Name, + ParentContentName = parentContentName + }); + } + } + + private static bool IsInstallVersionDirectory(DirectoryInfo directory) + { + return directory.EnumerateFiles("*", SearchOption.AllDirectories).Any(); + } + + /// + /// Deletes the temporary package staging directory for a content version when it exists. + /// + private void DeletePackageStagingDirectory( + LauncherPaths paths, + OwnedContentPath versionPath, + LauncherContentKey contentKey) + { + OwnedContentPath packageStagingPath = paths.GetPackageTemporaryPath(versionPath); + + DeleteDirectoryIfExists( + packageStagingPath, + "Deleted temporary launcher package staging folder for", + contentKey.Name, + contentKey.Version); + DeleteEmptyPackageStagingParents(packageStagingPath); + } + + /// + /// Deletes the durable recovery backup for content the user deliberately removed. + /// + private void DeletePackageBackupDirectory( + LauncherPaths paths, + OwnedContentPath installedPath, + LauncherContentKey contentKey) + { + OwnedContentPath packageBackupPath = paths.GetPackageBackupPath(installedPath); + + DeleteDirectoryIfExists( + packageBackupPath, + "Deleted obsolete launcher package recovery backup for", + contentKey.Name, + contentKey.Version); + DeleteEmptyPackageBackupParents(packageBackupPath); + } + + /// + /// Deletes empty package staging parent directories without crossing outside the package staging root. + /// + private void DeleteEmptyPackageStagingParents(OwnedContentPath packageStagingPath) + { + foreach (string deletedDirectory in OwnedDirectoryTree.DeleteEmptyParents( + packageStagingPath.OwnerRoot, + packageStagingPath.FullPath)) + { + _logger.LogDebug( + "Deleted empty temporary launcher package staging folder {StagingFolderName}.", + Path.GetFileName(deletedDirectory)); + } + } + + /// + /// Deletes empty recovery-backup parent directories without crossing outside the package backup root. + /// + private void DeleteEmptyPackageBackupParents(OwnedContentPath packageBackupPath) + { + foreach (string deletedDirectory in OwnedDirectoryTree.DeleteEmptyParentsIncludingRoot( + packageBackupPath.OwnerRoot, + packageBackupPath.FullPath)) + { + _logger.LogDebug( + "Deleted empty launcher package recovery folder {BackupFolderName}.", + Path.GetFileName(deletedDirectory)); + } + } + + private bool DeleteDirectoryIfExists( + OwnedContentPath ownedPath, + string deletionDescription, + string contentName, + string contentVersion) + { + if (!OwnedDirectoryTree.DeleteIfExists(ownedPath)) + { + return false; + } + + // The template stays constant so structured sinks can group these; the part + // that varies per caller is carried as a property rather than in the template. + _logger.LogInformation( + "{DeletionDescription} {ContentName} {ContentVersion}.", + deletionDescription, + contentName, + contentVersion); + return true; + } + + private static bool ContentCardExists(LauncherData launcherData, string contentName) + { + return launcherData.AllContent + .Any(entry => entry.ContentKey.HasName(contentName)); + } + +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/FileSystemManualModificationImporter.cs b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemManualModificationImporter.cs new file mode 100644 index 00000000..93e330ac --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemManualModificationImporter.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Archives.Contracts; +using GenLauncherGO.Infrastructure.Common; +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. +/// +internal sealed class FileSystemManualModificationImporter : IManualModificationImporter +{ + private readonly IArchiveExtractor _archiveExtractor; + + private readonly ILogger _logger; + + public FileSystemManualModificationImporter( + IArchiveExtractor archiveExtractor, + ILogger logger) + { + _archiveExtractor = archiveExtractor ?? throw new ArgumentNullException(nameof(archiveExtractor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Import( + IReadOnlyList sourceFilePaths, + OwnedContentPath destinationPath, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(sourceFilePaths); + ArgumentNullException.ThrowIfNull(destinationPath); + + if (sourceFilePaths.Count == 0) + { + throw new ArgumentException("At least one source file is required.", nameof(sourceFilePaths)); + } + + string destinationDirectory = destinationPath.FullPath; + try + { + foreach (string sourceFilePath in sourceFilePaths) + { + cancellationToken.ThrowIfCancellationRequested(); + destinationDirectory = PrepareSafeDestination(destinationPath); + ImportFile( + sourceFilePath, + destinationPath, + destinationDirectory, + cancellationToken); + } + + _logger.LogInformation( + "Imported {FileCount} manual content file(s) to {DestinationDirectory}.", + 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; + } + } + + private void ImportFile( + string sourceFilePath, + OwnedContentPath destinationPath, + 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 = ResolveSafeDestinationFilePath( + destinationDirectory, + Path.Combine(destinationDirectory, sourceFileName)); + if (!File.Exists(destinationFilePath)) + { + File.Copy(sourceFilePath, destinationFilePath); + } + + if (LauncherContentFileTypes.IsArchive(sourceFileName)) + { + destinationDirectory = PrepareSafeDestination(destinationPath); + destinationFilePath = ResolveSafeDestinationFilePath( + destinationDirectory, + destinationFilePath); + _archiveExtractor.ExtractToDirectory( + destinationFilePath, + destinationDirectory, + cancellationToken: cancellationToken); + destinationDirectory = PrepareSafeDestination(destinationPath); + ResolveSafeDestinationFilePath(destinationDirectory, destinationFilePath); + File.Delete(destinationFilePath); + return; + } + + string installedFilePath = BigFileVariantPath.GetInstalledPath(destinationFilePath); + if (!LexicalPath.AreEquivalent(installedFilePath, destinationFilePath)) + { + string gibFilePath = ResolveSafeDestinationFilePath( + destinationDirectory, + installedFilePath); + File.Move(destinationFilePath, gibFilePath); + } + } + + /// + /// Creates the owned destination when needed and rejects any linked path before mutation or extraction. + /// + private static string PrepareSafeDestination(OwnedContentPath destinationPath) + { + string destinationDirectory = FileSystemPathSafety.ResolveOwnedSubpath( + destinationPath.OwnerRoot, + destinationPath.FullPath, + "Manual import destinations", + "their launcher-owned root"); + destinationDirectory = OwnedDirectoryTree.EnsureExists( + destinationPath.OwnerRoot, + destinationDirectory); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + destinationDirectory, + "Manual import destinations"); + return destinationDirectory; + } + + /// + /// Resolves one destination file and rejects paths or existing entries outside the safe import directory. + /// + private static string ResolveSafeDestinationFilePath( + string destinationDirectory, + string candidatePath) + { + return FileSystemPathSafety.ResolveOwnedSubpath( + destinationDirectory, + candidatePath, + "Manual import files", + "their destination directory"); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationImageFileService.cs b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationImageFileService.cs new file mode 100644 index 00000000..7b0a51b0 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationImageFileService.cs @@ -0,0 +1,243 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Mods.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Manages cached modification image files on disk. +/// +internal sealed class FileSystemModificationImageFileService : IModificationImageFileService +{ + private readonly ILogger _logger; + private readonly LauncherRuntimePathContext _runtimePathContext; + + public FileSystemModificationImageFileService( + LauncherRuntimePathContext runtimePathContext, + ILogger logger) + { + _runtimePathContext = runtimePathContext ?? throw new ArgumentNullException(nameof(runtimePathContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public string? FindExistingImageFilePath( + ModificationType modificationType, + string modificationName, + string imageBaseName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modificationName); + ArgumentException.ThrowIfNullOrWhiteSpace(imageBaseName); + + LauncherPaths paths = _runtimePathContext.ActivePaths; + string? imageDirectory = ResolveExistingImageDirectory(paths, modificationType, modificationName); + if (imageDirectory is null) + { + return null; + } + + string? imageFilePath = Directory.EnumerateFiles( + imageDirectory, + GetImageSearchPattern(paths, modificationType, modificationName, imageBaseName)) + .FirstOrDefault(); + return imageFilePath is null + ? null + : ModificationImageCachePath.ResolvePath(paths, imageFilePath); + } + + public int CountImageFiles(ModificationType modificationType, string modificationName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modificationName); + + LauncherPaths paths = _runtimePathContext.ActivePaths; + string? imageDirectory = ResolveExistingImageDirectory(paths, modificationType, modificationName); + if (imageDirectory is null) + { + return 0; + } + + return Directory.EnumerateFiles(imageDirectory).Count(); + } + + public bool ImageExists(string? imageFilePath) + { + if (string.IsNullOrWhiteSpace(imageFilePath)) + { + return false; + } + + try + { + return File.Exists(ModificationImageCachePath.ResolvePath( + _runtimePathContext.ActivePaths, + imageFilePath)); + } + catch (Exception exception) when (ModificationCacheFailure.IsRecoverable(exception)) + { + return false; + } + } + + public bool TryDeleteImage( + ModificationType modificationType, + string modificationName, + string imageBaseName) + { + try + { + LauncherPaths paths = _runtimePathContext.ActivePaths; + string? imageDirectory = ResolveExistingImageDirectory(paths, modificationType, modificationName); + if (imageDirectory is null) + { + return true; + } + + string imageSearchPattern = GetImageSearchPattern( + paths, + modificationType, + modificationName, + imageBaseName); + foreach (string imageFilePath in Directory.EnumerateFiles(imageDirectory, imageSearchPattern).ToList()) + { + File.Delete(ModificationImageCachePath.ResolvePath(paths, imageFilePath)); + } + + return true; + } + catch (Exception exception) when (ModificationCacheFailure.IsRecoverable(exception)) + { + _logger.LogWarning( + exception, + "Could not remove cached modification image {ImageBaseName} for {ModificationName}.", + imageBaseName, + modificationName); + return false; + } + } + + public Task ReplaceImageAsync( + ModificationImageReplacementRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + LauncherPaths paths = _runtimePathContext.ActivePaths; + return Task.Run(() => ReplaceImage(paths, request, cancellationToken), cancellationToken); + } + + /// + /// Replaces the cached image file and removes stale sibling extensions. + /// + private string ReplaceImage( + LauncherPaths paths, + 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 sourcePath = LexicalPath.NormalizeFullPath(request.SourceImagePath); + + try + { + string destinationDirectory = OwnedDirectoryTree.EnsureExists( + paths.ImagesDirectory, + ModificationImageCachePath.ResolveDirectory( + paths, + ModificationType.Mod, + request.ModificationName)); + string destinationPath = ModificationImageCachePath.ResolveImagePath( + paths, + ModificationType.Mod, + request.ModificationName, + request.ImageBaseName + extension); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + destinationDirectory, + "Cached modification image directories"); + + if (LexicalPath.AreEquivalent(sourcePath, destinationPath)) + { + return destinationPath; + } + + string imageSearchPattern = Path.GetFileNameWithoutExtension(destinationPath) + ".*"; + foreach (string existingImagePath in Directory.EnumerateFiles(destinationDirectory, imageSearchPattern)) + { + cancellationToken.ThrowIfCancellationRequested(); + File.Delete(ModificationImageCachePath.ResolvePath(paths, existingImagePath)); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Copy(sourcePath, destinationPath); + return destinationPath; + } + catch (Exception exception) when (ModificationCacheFailure.IsRecoverable(exception)) + { + _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); + } + } + + /// + /// Resolves an existing image directory and rejects linked entries before callers enumerate it. + /// + private static string? ResolveExistingImageDirectory( + LauncherPaths paths, + ModificationType modificationType, + string modificationName) + { + string imageDirectory = ModificationImageCachePath.ResolveDirectory( + paths, + modificationType, + modificationName); + if (!Directory.Exists(imageDirectory)) + { + return null; + } + + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + imageDirectory, + "Cached modification image directories"); + return imageDirectory; + } + + private static string GetImageSearchPattern( + LauncherPaths paths, + ModificationType modificationType, + string modificationName, + string imageBaseName) + { + string validatedImagePath = ModificationImageCachePath.ResolveImagePath( + paths, + modificationType, + modificationName, + imageBaseName + ".cache"); + return Path.GetFileNameWithoutExtension(validatedImagePath) + ".*"; + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationThemeCache.cs b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationThemeCache.cs new file mode 100644 index 00000000..7e62ec08 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationThemeCache.cs @@ -0,0 +1,133 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Mods.Support; +using GenLauncherGO.Infrastructure.Persistence.Services; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Caches published modification palettes as YAML beside the artwork they belong to. +/// +/// +/// Reusing the modification image cache directory is deliberate: that folder already has an ownership boundary, +/// reparse-point defences, and removal when the content card goes away, so the palette inherits all of it instead +/// of needing a second cache location with its own lifetime rules. +/// +internal sealed class FileSystemModificationThemeCache : IModificationThemeCache +{ + private readonly IAtomicFileWriter _atomicFileWriter; + + private readonly ILogger> _documentLogger; + + private readonly ILogger _logger; + private readonly LauncherRuntimePathContext _runtimePathContext; + + public FileSystemModificationThemeCache( + LauncherRuntimePathContext runtimePathContext, + IAtomicFileWriter atomicFileWriter, + ILogger> documentLogger, + ILogger logger) + { + _runtimePathContext = runtimePathContext ?? throw new ArgumentNullException(nameof(runtimePathContext)); + _atomicFileWriter = atomicFileWriter ?? throw new ArgumentNullException(nameof(atomicFileWriter)); + _documentLogger = documentLogger ?? throw new ArgumentNullException(nameof(documentLogger)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Save(LauncherContentKey contentKey, LauncherContentTheme theme) + { + ArgumentNullException.ThrowIfNull(theme); + + if (!TryResolveDocumentPath(contentKey, out string documentPath)) + { + return; + } + + try + { + OwnedDirectoryTree.EnsureExists( + _runtimePathContext.ActivePaths.ImagesDirectory, + Path.GetDirectoryName(documentPath)!); + CreateDocumentStore(documentPath).Save(theme); + } + catch (Exception exception) when (ModificationCacheFailure.IsRecoverable(exception)) + { + // A palette that cannot be cached only costs the offline re-skin, so never fail the catalog for it. + _logger.LogWarning( + exception, + "Could not cache the published palette for {ModificationName} {Version}.", + contentKey.Name, + contentKey.Version); + } + } + + public LauncherContentTheme? Load(LauncherContentKey contentKey) + { + if (!TryResolveDocumentPath(contentKey, out string documentPath)) + { + return null; + } + + try + { + IYamlDocumentStore store = CreateDocumentStore(documentPath); + if (!store.DocumentExists) + { + return null; + } + + LauncherContentTheme empty = new(); + LauncherContentTheme cached = store.Load(empty); + return ReferenceEquals(cached, empty) ? null : cached; + } + catch (Exception exception) when (ModificationCacheFailure.IsRecoverable(exception)) + { + _logger.LogWarning( + exception, + "Could not read the cached palette for {ModificationName} {Version}.", + contentKey.Name, + contentKey.Version); + return null; + } + } + + private bool TryResolveDocumentPath(LauncherContentKey contentKey, out string documentPath) + { + documentPath = string.Empty; + if (string.IsNullOrWhiteSpace(contentKey.Name) || string.IsNullOrWhiteSpace(contentKey.Version)) + { + return false; + } + + try + { + LauncherPaths paths = _runtimePathContext.ActivePaths; + documentPath = ModificationImageCachePath.ResolveImagePath( + paths, + contentKey.ContentType, + contentKey.Name, + LauncherContentTheme.ResolveCacheBaseName(contentKey.Version) + ".yaml"); + return true; + } + catch (Exception exception) when (ModificationCacheFailure.IsRecoverable(exception)) + { + _logger.LogWarning( + exception, + "Could not resolve the palette cache path for {ModificationName} {Version}.", + contentKey.Name, + contentKey.Version); + return false; + } + } + + private IYamlDocumentStore CreateDocumentStore(string documentPath) + { + return new YamlDocumentStore(documentPath, _atomicFileWriter, _documentLogger); + } + +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherCatalogImageCache.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherCatalogImageCache.cs new file mode 100644 index 00000000..57a6e553 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherCatalogImageCache.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +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.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Support; +using GenLauncherGO.Infrastructure.Remote.Contracts; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Caches the assets a remote launcher catalog publishes: tile art, shell artwork, and palettes. +/// +/// +/// Everything cached here shares one lifetime, because it all lands in the modification's own cache folder and is +/// removed with the content card. +/// +internal sealed class LauncherCatalogImageCache +{ + private readonly IRemoteAssetDownloader _assetDownloader; + + private readonly ILogger _logger; + + private readonly IModificationThemeCache _themeCache; + + public LauncherCatalogImageCache( + IRemoteAssetDownloader assetDownloader, + IModificationThemeCache themeCache, + ILogger logger) + { + _assetDownloader = assetDownloader ?? throw new ArgumentNullException(nameof(assetDownloader)); + _themeCache = themeCache ?? throw new ArgumentNullException(nameof(themeCache)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task CacheModificationImagesAsync( + LauncherContentVersion modification, + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(modification); + ArgumentNullException.ThrowIfNull(paths); + + bool themeSaved = false; + foreach ((string link, string baseName, bool requiresTheme) in + ModificationImageCachePath.GetRemoteAssetSources(modification)) + { + if (requiresTheme && !themeSaved) + { + SaveTheme(modification); + themeSaved = true; + } + + await DownloadImageIfMissingAsync( + paths, + modification.ModificationType, + modification.Name, + baseName, + link, + cancellationToken).ConfigureAwait(false); + } + + if (modification.Theme is not null && !themeSaved) + { + SaveTheme(modification); + } + } + + private void SaveTheme(LauncherContentVersion modification) + { + // Cached ahead of selection so a themed modification can re-skin the shell the moment it is picked, and + // so a later offline run still has both the palette and the artwork it refers to. + _themeCache.Save(modification.ContentKey, modification.Theme!); + } + + 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, + ModificationType.Advertising, + advertising.Name, + currentImageIndex.ToString(CultureInfo.CurrentCulture), + imageLink, + cancellationToken)); + imageIndex++; + } + + await Task.WhenAll(imageDownloads).ConfigureAwait(false); + } + + /// + /// Removes stale advertising image files when the remote image count changes. + /// + private void RemoveStaleAdvertisingImages( + RemoteAdvertisingReference advertising, + LauncherPaths paths) + { + try + { + string imageFolderPath = ModificationImageCachePath.ResolveDirectory( + paths, + ModificationType.Advertising, + advertising.Name); + if (!Directory.Exists(imageFolderPath)) + { + return; + } + + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + imageFolderPath, + "Cached catalog image directories"); + var dirInfo = new DirectoryInfo(imageFolderPath); + FileInfo[] images = dirInfo.GetFiles(); + if (images.Length == advertising.ImageUrls.Count) + { + return; + } + + foreach (FileInfo image in images) + { + try + { + File.Delete(ModificationImageCachePath.ResolvePath(paths, image.FullName)); + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to delete stale advertising image {ImageFileName}.", + image.Name); + } + } + } + catch (Exception exception) when (ModificationCacheFailure.IsRecoverable(exception)) + { + _logger.LogWarning( + exception, + "Skipped stale advertising image cleanup for {ModificationName} because its cache path was unavailable.", + advertising.Name); + } + } + + private async Task DownloadImageIfMissingAsync( + LauncherPaths paths, + ModificationType modificationType, + string modificationName, + string fileName, + string link, + CancellationToken cancellationToken) + { + try + { + var sourceUri = new Uri(link, UriKind.Absolute); + string imageDirectory = OwnedDirectoryTree.EnsureExists( + paths.ImagesDirectory, + ModificationImageCachePath.ResolveDirectory(paths, modificationType, modificationName)); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + imageDirectory, + "Cached catalog image directories"); + string destinationFilePath = ModificationImageCachePath.ResolveRemoteImagePath( + paths, + modificationType, + modificationName, + fileName, + sourceUri); + await _assetDownloader.DownloadIfMissingAsync( + sourceUri, + destinationFilePath, + cancellationToken).ConfigureAwait(false); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + imageDirectory, + "Cached catalog image directories"); + _ = ModificationImageCachePath.ResolvePath(paths, destinationFilePath); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to download cached image {ImageName} for {ModificationName}.", + fileName, + modificationName); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentCatalogService.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentCatalogService.cs new file mode 100644 index 00000000..24b83415 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentCatalogService.cs @@ -0,0 +1,535 @@ +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.Exceptions; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Models; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Coordinates remote launcher catalog data, local content state, cached images, and selection persistence. +/// +internal sealed class LauncherContentCatalogService : ILauncherContentCatalog +{ + private const int MaxConcurrentImageCacheUpdates = 8; + + /// + /// Serializes asynchronous catalog mutation so in-flight work cannot cross a game-session boundary. + /// + private readonly SemaphoreSlim _catalogMutationGate = new(1, 1); + + private readonly ILauncherContentStateStore _contentStateStore; + + private readonly LauncherCatalogImageCache _imageCache; + + private readonly LauncherLocalContentReconciler _localContentReconciler; + + private readonly ILogger _logger; + + private readonly RemoteLauncherCatalogClient _remoteCatalogClient; + + private readonly IModificationThemeCache _themeCache; + + private CatalogSessionState _state = new(); + + public LauncherContentCatalogService( + ILauncherContentStateStore contentStateStore, + RemoteLauncherCatalogClient remoteCatalogClient, + LauncherCatalogImageCache imageCache, + LauncherLocalContentReconciler localContentReconciler, + IModificationThemeCache themeCache, + ILogger logger) + { + _contentStateStore = contentStateStore ?? throw new ArgumentNullException(nameof(contentStateStore)); + _remoteCatalogClient = remoteCatalogClient ?? throw new ArgumentNullException(nameof(remoteCatalogClient)); + _imageCache = imageCache ?? throw new ArgumentNullException(nameof(imageCache)); + _localContentReconciler = + localContentReconciler ?? throw new ArgumentNullException(nameof(localContentReconciler)); + _themeCache = themeCache ?? throw new ArgumentNullException(nameof(themeCache)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + private LauncherPaths Paths => + _state.Paths ?? throw new InvalidOperationException("Launcher content catalog has not been initialized."); + + public LauncherData Data => _state.Data; + + public LauncherContentVersion? Advertising => _state.Advertising; + + public IReadOnlyList? RepositoryModificationNames => _state.RepositoryModificationNames; + + public async Task InitDataAsync( + LauncherContentCatalogInitializationRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.Paths); + + await RunWithCatalogMutationGateAsync( + async () => + { + CatalogSessionState previousState = _state; + try + { + _state = new CatalogSessionState(request.Paths); + await InitializeDataCoreAsync(request, cancellationToken).ConfigureAwait(false); + } + catch + { + _state = previousState; + throw; + } + }, + cancellationToken).ConfigureAwait(false); + } + + public async Task ReadOriginalGameAddonsAndPatchesAsync(CancellationToken cancellationToken) + { + await RunWithCatalogMutationGateAsync( + () => ReadOriginalGameAddonsAndPatchesCoreAsync(cancellationToken), + cancellationToken).ConfigureAwait(false); + } + + public async Task GetRepositoryModificationMetadataAsync( + string name, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + return await RunWithCatalogMutationGateAsync( + async () => + { + RemoteModificationManifest manifest = await GetRepositoryModificationManifestAsync( + name, + cancellationToken).ConfigureAwait(false); + return manifest.Content; + }, + cancellationToken).ConfigureAwait(false); + } + + public async Task AddRepositoryModificationAsync( + string name, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + return await RunWithCatalogMutationGateAsync( + async () => + { + RemoteModificationManifest manifest = await GetRepositoryModificationManifestAsync( + name, + cancellationToken).ConfigureAwait(false); + AddRemoteModificationManifest(manifest); + + await _imageCache.CacheModificationImagesAsync(manifest.Content, Paths, cancellationToken) + .ConfigureAwait(false); + AddDownloadedModificationData(manifest.Content); + return manifest.Content; + }, + cancellationToken).ConfigureAwait(false); + } + + public void UninstallVersion(LauncherContentKey contentKey) + { + _localContentReconciler.DeleteVersion(contentKey, Paths); + UpdateLocalModificationsData(); + } + + public void DiscardVersion(LauncherContentKey contentKey) + { + _localContentReconciler.DiscardVersion(_state.Data, contentKey, Paths); + UpdateLocalModificationsData(); + } + + public void DiscardContent(LauncherContentKey contentKey) + { + _localContentReconciler.DiscardContent(_state.Data, contentKey, Paths); + UpdateLocalModificationsData(); + } + + public async Task ReadPatchesAndAddonsForModAsync( + LauncherContentKey modificationKey, + CancellationToken cancellationToken) + { + await RunWithCatalogMutationGateAsync( + () => ReadPatchesAndAddonsForModCoreAsync(modificationKey, cancellationToken), + cancellationToken).ConfigureAwait(false); + } + + public void UpdateLocalModificationsData() + { + _localContentReconciler.Reconcile(_state.Data, _state.DownloadedRepositoryContent, Paths); + } + + public void SaveLauncherData() + { + try + { + _contentStateStore.Save(Paths, LauncherContentStateMapper.ToLauncherContentState(_state.Data)); + } + catch (Exception exception) + { + _logger.LogError( + exception, + "Failed to persist launcher content state. The current in-memory catalog remains available for retry."); + throw new LauncherContentPersistenceException(exception); + } + } + + private async Task RunWithCatalogMutationGateAsync( + Func operation, + CancellationToken cancellationToken) + { + await _catalogMutationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await operation().ConfigureAwait(false); + } + finally + { + _catalogMutationGate.Release(); + } + } + + private async Task RunWithCatalogMutationGateAsync( + Func> operation, + CancellationToken cancellationToken) + { + await _catalogMutationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await operation().ConfigureAwait(false); + } + finally + { + _catalogMutationGate.Release(); + } + } + + /// + /// Reads and caches one manifest while the caller owns the catalog mutation gate. + /// + private async Task GetRepositoryModificationManifestAsync( + string name, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var contentKey = LauncherContentKey.ForModificationName(name); + if (_state.RepositoryMetadataCache.TryGetValue( + contentKey, + out RemoteModificationManifest? cachedManifest)) + { + return cachedManifest; + } + + RemoteModificationManifest manifest = await _remoteCatalogClient.DownloadModDataByNameAsync( + _state.RepositoryData ?? RemoteLauncherCatalog.Empty, + name, + cancellationToken).ConfigureAwait(false); + _state.RepositoryMetadataCache[contentKey] = manifest; + return manifest; + } + + private void ReadLocalModsData() + { + _state.Data = LauncherContentStateMapper.ToLauncherData(_contentStateStore.Load(Paths), _themeCache); + } + + /// + /// Loads one game-specific catalog after the previous state has been isolated for rollback. + /// + private async Task InitializeDataCoreAsync( + LauncherContentCatalogInitializationRequest request, + CancellationToken cancellationToken) + { + if (request.RemoteManifestUri is not null) + { + await ReadMainManifestAsync(request.RemoteManifestUri, cancellationToken).ConfigureAwait(false); + } + + ReadLocalModsData(); + UpdateLocalModificationsData(); + + if (_state.RepositoryData is null) + { + LogCatalogInitialized(); + return; + } + + RemoteLauncherCatalog repositoryData = _state.RepositoryData; + var installedMods = _state.Data.Modifications.Select(mod => mod.Name).ToList(); + _state.RepositoryModificationNames = _remoteCatalogClient.GetModificationNames(repositoryData); + + IReadOnlyList installedManifests = await _remoteCatalogClient + .DownloadInstalledModDataAsync( + repositoryData, + installedMods, + cancellationToken) + .ConfigureAwait(false); + _state.ModificationsAndAddons = ToManifestDictionary(installedManifests); + var reposMods = installedManifests.Select(manifest => manifest.Content).ToList(); + + await CacheInstalledModificationImagesAsync(reposMods, cancellationToken).ConfigureAwait(false); + + foreach (LauncherContentVersion reposMod in reposMods) + { + AddDownloadedModificationData(reposMod); + } + + LauncherContent? selectedMod = _state.Data.GetSelectedMod(); + if (selectedMod != null) + { + await ReadPatchesAndAddonsForModCoreAsync(selectedMod.ContentKey, cancellationToken) + .ConfigureAwait(false); + } + + LogCatalogInitialized(); + } + + /// + /// Loads original-game children while the caller owns the catalog mutation gate. + /// + private async Task ReadOriginalGameAddonsAndPatchesCoreAsync(CancellationToken cancellationToken) + { + if (_state.RepositoryData is null) + { + return; + } + + RemoteLauncherCatalog repositoryData = _state.RepositoryData; + LauncherContentKey originalGameKey = LauncherContentKey.OriginalGame; + if (_state.DownloadedModificationInfo.Contains(originalGameKey)) + { + return; + } + + await LoadChildContentAsync( + originalGameKey, + repositoryData.OriginalGamePatchManifestUrls, + repositoryData.OriginalGameAddonManifestUrls, + originalGameKey.Name, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Loads one modification's children while the caller owns the catalog mutation gate. + /// + private async Task ReadPatchesAndAddonsForModCoreAsync( + LauncherContentKey modificationKey, + CancellationToken cancellationToken) + { + if (_state.RepositoryData is null) + { + return; + } + + var keyModification = LauncherContentKey.ForModificationName(modificationKey.Name); + if (_state.DownloadedModificationInfo.Contains(keyModification)) + { + return; + } + + if (!_state.ModificationsAndAddons.TryGetValue( + keyModification, + out RemoteModificationManifest? modData)) + { + return; + } + + await LoadChildContentAsync( + keyModification, + modData.PatchManifestUrls, + modData.AddonManifestUrls, + null, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Loads a content card's patch and add-on manifests concurrently and records a complete load only when both + /// groups succeeded. Successful partial results remain available while a later call retries failed manifests. + /// + private async Task LoadChildContentAsync( + LauncherContentKey contentKey, + IEnumerable patchManifestUrls, + IEnumerable addonManifestUrls, + string? parentContentName, + CancellationToken cancellationToken) + { + Task patchesTask = _remoteCatalogClient.ReadChildManifestsAsync( + patchManifestUrls, + parentContentName, + cancellationToken); + Task addonsTask = _remoteCatalogClient.ReadChildManifestsAsync( + addonManifestUrls, + parentContentName, + cancellationToken); + RemoteChildManifestLoadResult[] childManifestLoads = + await Task.WhenAll(patchesTask, addonsTask).ConfigureAwait(false); + RemoteChildManifestLoadResult patchLoad = childManifestLoads[0]; + RemoteChildManifestLoadResult addonLoad = childManifestLoads[1]; + + foreach (LauncherContentVersion patch in patchLoad.ContentVersions) + { + AddDownloadedModificationData(patch); + } + + foreach (LauncherContentVersion addon in addonLoad.ContentVersions) + { + AddDownloadedModificationData(addon); + } + + if (patchLoad.Succeeded && addonLoad.Succeeded) + { + _state.DownloadedModificationInfo.Add(contentKey); + } + } + + /// + /// Reads the top-level remote manifest and related advertising metadata. + /// + private async Task ReadMainManifestAsync(Uri manifestUri, CancellationToken cancellationToken) + { + RemoteLauncherCatalog repositoryData = await _remoteCatalogClient.ReadCatalogAsync( + manifestUri, + cancellationToken).ConfigureAwait(false); + _state.RepositoryData = repositoryData; + + if (repositoryData.AdvertisingEntries.Count > 0) + { + await DownloadAdvertisingDataAsync( + repositoryData.AdvertisingEntries[0], + cancellationToken).ConfigureAwait(false); + } + } + + private void AddDownloadedModificationData(LauncherContentVersion version) + { + _state.Data.AddOrUpdate(version); + _state.DownloadedRepositoryContent.Add(version.ContentKey); + } + + /// + /// Caches installed modification images with bounded parallelism. + /// + 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. + /// + private async Task CacheModificationImagesAsync( + LauncherContentVersion modification, + SemaphoreSlim semaphore, + CancellationToken cancellationToken) + { + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await _imageCache.CacheModificationImagesAsync(modification, Paths, cancellationToken) + .ConfigureAwait(false); + } + finally + { + semaphore.Release(); + } + } + + private async Task DownloadAdvertisingDataAsync( + RemoteAdvertisingReference advertisingData, + CancellationToken cancellationToken) + { + _state.Advertising = await _remoteCatalogClient.DownloadAdvertisingInfoAsync( + advertisingData.ManifestUrl, + cancellationToken).ConfigureAwait(false); + if (_state.Advertising is null) + { + return; + } + + await _imageCache.CacheAdvertisingImagesAsync(advertisingData, Paths, cancellationToken).ConfigureAwait(false); + } + + private static Dictionary ToManifestDictionary( + IEnumerable manifests) + { + var result = new Dictionary(); + foreach (RemoteModificationManifest manifest in manifests) + { + var key = LauncherContentKey.ForModificationName(manifest.Content.Name); + result.TryAdd(key, manifest); + } + + return result; + } + + private void AddRemoteModificationManifest(RemoteModificationManifest manifest) + { + var key = LauncherContentKey.ForModificationName(manifest.Content.Name); + _state.ModificationsAndAddons.TryAdd(key, manifest); + } + + /// + /// Logs a compact catalog initialization summary without local paths or remote URLs. + /// + private void LogCatalogInitialized() + { + _logger.LogInformation( + "Initialized launcher content catalog. Connected: {Connected}; modifications: {ModificationCount}; " + + "patches: {PatchCount}; add-ons: {AddonCount}; versions: {VersionCount}; " + + "repository modifications: {RepositoryModificationCount}.", + _state.RepositoryData is not null, + _state.Data.Modifications.Count, + _state.Data.Patches.Count, + _state.Data.Addons.Count, + CountVersions(_state.Data), + RepositoryModificationNames?.Count ?? 0); + } + + private static int CountVersions(LauncherData data) + { + return data.AllContent.Sum(content => content.Versions.Count); + } + + private sealed class CatalogSessionState + { + public CatalogSessionState(LauncherPaths? paths = null) + { + Paths = paths; + } + + public LauncherData Data { get; set; } = new(); + + public Dictionary ModificationsAndAddons { get; set; } = + []; + + public Dictionary RepositoryMetadataCache { get; } = []; + + public HashSet DownloadedModificationInfo { get; } = []; + + public HashSet DownloadedRepositoryContent { get; } = []; + + public LauncherContentVersion? Advertising { get; set; } + + public LauncherPaths? Paths { get; } + + public RemoteLauncherCatalog? RepositoryData { get; set; } + + public IReadOnlyList? RepositoryModificationNames { get; set; } + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentStateMapper.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentStateMapper.cs new file mode 100644 index 00000000..0849b292 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentStateMapper.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Maps compact legacy-compatible launcher content state to and from the active catalog. +/// +internal static class LauncherContentStateMapper +{ + /// + /// Rebuilds the catalog from persisted state, restoring each version's cached palette when one is supplied. + /// + /// The persisted local content state. + /// + /// The palette cache, or to rebuild without palettes. Persisted state deliberately + /// holds no remote manifest metadata, so a themed launcher only survives an offline restart because the + /// palette is restored here from its own cache. + /// + public static LauncherData ToLauncherData(LauncherContentState state, IModificationThemeCache? themeCache = null) + { + ArgumentNullException.ThrowIfNull(state); + + var launcherData = new LauncherData(); + AddStoredVersions(launcherData, state.Modifications, ModificationType.Mod, themeCache); + AddStoredVersions(launcherData, state.Addons, ModificationType.Addon, themeCache); + AddStoredVersions(launcherData, state.Patches, ModificationType.Patch, themeCache); + return launcherData; + } + + public static LauncherContentState ToLauncherContentState(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + return new LauncherContentState + { + Modifications = ToEntryStates(launcherData.Modifications, ModificationType.Mod), + Addons = ToEntryStates(launcherData.Addons, ModificationType.Addon), + Patches = ToEntryStates(launcherData.Patches, ModificationType.Patch) + }; + } + + private static LauncherContentVersionState ToVersionState( + LauncherContentVersion version, + ModificationType fallbackType, + bool entryIsSelected) + { + ArgumentNullException.ThrowIfNull(version); + + return new LauncherContentVersionState + { + ModificationType = ResolvePersistedContentType(version.ModificationType, fallbackType), + Name = version.Name ?? string.Empty, + Version = version.Version ?? string.Empty, + DependenceName = version.ParentContentName ?? string.Empty, + Installed = version.Installation.Installed, + IsSelected = entryIsSelected && version.Installation.IsSelected, + DownloadSuspended = version.Installation.DownloadSuspended, + SuspendedProgressPercentage = version.Installation.SuspendedProgressPercentage, + ContentSourceKind = version.Installation.ContentSourceKind + }; + } + + private static void AddStoredVersions( + LauncherData launcherData, + IEnumerable entries, + ModificationType fallbackType, + IModificationThemeCache? themeCache) + { + foreach (LauncherContentEntryState entry in entries ?? []) + { + LauncherContent? storedModification = null; + foreach (LauncherContentVersionState version in entry.ModificationVersions ?? + []) + { + LauncherContentVersion modificationVersion = ToModificationVersion( + entry, + version, + fallbackType, + themeCache); + launcherData.AddOrUpdate(modificationVersion); + storedModification ??= launcherData.FindContent(modificationVersion.ContentKey); + } + + if (storedModification != null) + { + storedModification.IsSelected = entry.IsSelected; + storedModification.NumberInList = entry.NumberInList; + } + } + } + + private static LauncherContentVersion ToModificationVersion( + LauncherContentEntryState entry, + LauncherContentVersionState version, + ModificationType fallbackType, + IModificationThemeCache? themeCache) + { + ModificationType contentType = ResolveContentType( + version.ModificationType, + entry.ModificationType, + fallbackType); + + var installation = new LauncherContentInstallation + { + Installed = version.Installed || entry.Installed, + IsSelected = entry.IsSelected && version.IsSelected, + DownloadSuspended = version.DownloadSuspended, + SuspendedProgressPercentage = version.SuspendedProgressPercentage, + ContentSourceKind = version.ContentSourceKind + }; + string contentName = CoalesceStateText(version.Name, entry.Name); + string contentVersion = version.Version ?? string.Empty; + string parentContentName = CoalesceStateText(version.DependenceName, entry.DependenceName); + var contentKey = new LauncherContentKey( + contentType, + parentContentName, + contentName, + contentVersion); + return new LauncherContentVersion(installation) + { + ModificationType = contentType, + Name = contentName, + Version = contentVersion, + ParentContentName = parentContentName, + Theme = themeCache?.Load(contentKey) + }; + } + + private static List ToEntryStates( + IEnumerable modifications, + ModificationType fallbackType) + { + var entries = new List(); + foreach (LauncherContent modification in modifications ?? []) + { + var versions = modification.Versions + .Where(version => ShouldPersistVersion(version, fallbackType)) + .Select(version => ToVersionState(version, fallbackType, modification.IsSelected)) + .ToList(); + + if (versions.Count == 0) + { + continue; + } + + entries.Add(new LauncherContentEntryState + { + ModificationType = ResolvePersistedContentType(modification.ModificationType, fallbackType), + Name = modification.Name ?? string.Empty, + DependenceName = modification.ContentKey.ParentIdentity, + Installed = modification.Installed, + IsSelected = modification.IsSelected, + NumberInList = modification.NumberInList, + ModificationVersions = versions + }); + } + + return entries; + } + + private static bool ShouldPersistVersion( + LauncherContentVersion version, + ModificationType fallbackType) + { + return version.Installation.Installed || + version.Installation.IsSelected || + // A suspended download has partial content on disk but is not installed yet, so it has to persist + // on its own merit or the next session would forget it and start over. + version.Installation.DownloadSuspended || + (fallbackType == ModificationType.Mod && + version.EffectiveContentSourceKind.IsManagedRemote()); + } + + private static string CoalesceStateText(string value, string fallback) + { + return !string.IsNullOrWhiteSpace(value) ? value : fallback ?? string.Empty; + } + + private static ModificationType ResolveContentType( + ModificationType versionType, + ModificationType entryType, + ModificationType fallbackType) + { + if (versionType != ModificationType.Mod || fallbackType == ModificationType.Mod) + { + return versionType; + } + + return entryType != ModificationType.Mod ? entryType : fallbackType; + } + + private static ModificationType ResolvePersistedContentType( + ModificationType type, + ModificationType fallbackType) + { + return type switch + { + ModificationType.Addon or ModificationType.Patch => type, + _ => fallbackType + }; + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherLocalContentReconciler.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherLocalContentReconciler.cs new file mode 100644 index 00000000..05728b54 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherLocalContentReconciler.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Reconciles launcher catalog state with local content folders. +/// +internal sealed class LauncherLocalContentReconciler +{ + private readonly ILocalLauncherContentService _localContentService; + + private readonly ILogger _logger; + + public LauncherLocalContentReconciler( + ILocalLauncherContentService localContentService, + ILogger logger) + { + _localContentService = localContentService ?? throw new ArgumentNullException(nameof(localContentService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Reconcile( + LauncherData launcherData, + IReadOnlyCollection downloadedReposContent, + LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(launcherData); + ArgumentNullException.ThrowIfNull(downloadedReposContent); + ArgumentNullException.ThrowIfNull(paths); + + _localContentService.DeleteEmptyPackageBackupDirectories(paths); + IReadOnlyList installedVersions = + _localContentService.FindInstalledVersions(paths); + + AddUnregisteredModifications(launcherData, installedVersions); + (int MarkedNotInstalledCount, int RemovedCount) changes = DeleteOutdatedModifications( + launcherData, + downloadedReposContent, + installedVersions, + paths); + _logger.LogInformation( + "Reconciled launcher catalog with local content folders. Local versions: {LocalVersionCount}; " + + "marked not installed: {MarkedNotInstalledCount}; " + + "removed stale catalog entries: {RemovedCatalogEntryCount}.", + installedVersions.Count, + changes.MarkedNotInstalledCount, + changes.RemovedCount); + } + + public void DeleteVersion( + LauncherContentKey contentKey, + LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + _localContentService.DeleteVersion(paths, contentKey); + } + + public void DiscardVersion( + LauncherData launcherData, + LauncherContentKey contentKey, + LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(launcherData); + ArgumentNullException.ThrowIfNull(paths); + + _localContentService.DeleteVersion(paths, contentKey); + launcherData.DeleteVersion(contentKey); + _localContentService.DeleteImagesIfUnused(paths, contentKey, launcherData); + } + + public void DiscardContent( + LauncherData launcherData, + LauncherContentKey contentKey, + LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(launcherData); + ArgumentNullException.ThrowIfNull(paths); + + _localContentService.DeleteContent(paths, contentKey); + launcherData.DeleteContent(contentKey); + _localContentService.DeleteImagesIfUnused(paths, contentKey, launcherData); + } + + private static void AddUnregisteredModifications( + LauncherData launcherData, + IEnumerable installedVersions) + { + foreach (LauncherContentVersion version in installedVersions) + { + launcherData.AddOrUpdate(version); + } + } + + /// + /// Removes local-only catalog entries whose folders no longer contain files. + /// + private (int MarkedNotInstalledCount, int RemovedCount) DeleteOutdatedModifications( + LauncherData launcherData, + IReadOnlyCollection downloadedReposContent, + IReadOnlyCollection installedVersions, + LauncherPaths paths) + { + var installedVersionIds = installedVersions + .Select(version => version.ContentKey) + .ToHashSet(); + int markedNotInstalledCount = 0; + int removedCount = 0; + + IReadOnlyList contentVersions = launcherData.AllContent + .SelectMany(content => content.Versions) + .DistinctBy(version => version.ContentKey) + .ToList(); + + foreach (LauncherContentVersion version in contentVersions) + { + (bool markedNotInstalled, bool removed) = CheckContentExistence( + launcherData, + downloadedReposContent, + version, + paths, + installedVersionIds); + if (markedNotInstalled) + { + markedNotInstalledCount++; + } + + if (removed) + { + removedCount++; + } + } + + return (markedNotInstalledCount, removedCount); + } + + /// + /// Removes or marks a content version when the local folder no longer contains files. + /// + private (bool MarkedNotInstalled, bool Removed) CheckContentExistence( + LauncherData launcherData, + IReadOnlyCollection downloadedReposContent, + LauncherContentVersion modificationVersion, + LauncherPaths paths, + HashSet installedVersionIds) + { + if (installedVersionIds.Contains(modificationVersion.ContentKey)) + { + return (false, false); + } + + if (downloadedReposContent.Contains(modificationVersion.ContentKey) || + modificationVersion.EffectiveContentSourceKind.IsManagedRemote()) + { + if (modificationVersion.Installation.Installed) + { + modificationVersion.Installation.Installed = false; + return (true, false); + } + } + else + { + launcherData.DeleteVersion(modificationVersion.ContentKey); + _localContentService.DeleteImagesIfUnused(paths, modificationVersion.ContentKey, launcherData); + return (false, true); + } + + return (false, false); + } + +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/RemoteLauncherCatalogClient.cs b/GenLauncherGO.Infrastructure/Mods/Services/RemoteLauncherCatalogClient.cs new file mode 100644 index 00000000..29d7ad1e --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/RemoteLauncherCatalogClient.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Support; +using GenLauncherGO.Infrastructure.Remote.Contracts; +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 +{ + private const int MaxConcurrentManifestReads = 6; + + private readonly ILogger _logger; + + private readonly IRemoteYamlDocumentReader _yamlDocumentReader; + + 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); + + LegacyLauncherCatalogDocument? 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 = installedModNames + .Select(LauncherContentKey.ForModificationName) + .ToHashSet(); + var installedModData = catalog.Modifications + .Where(reference => + string.IsNullOrEmpty(reference.Name) || + downloadedModNames.Contains(LauncherContentKey.ForModificationName(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(); + foreach (RemoteModificationManifest? result in results) + { + if (result is null) + { + continue; + } + + var key = LauncherContentKey.ForModificationName(result.Content.Name); + mods.TryAdd(key, 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 => LauncherContentKey.ForModificationName(data.Name) == + LauncherContentKey.ForModificationName(name)); + + return await DownloadModDataAsync(reference, cancellationToken).ConfigureAwait(false); + } + + public async Task ReadChildManifestsAsync( + IEnumerable manifestUrls, + string? parentContentName, + CancellationToken cancellationToken) + { + using var semaphore = new SemaphoreSlim(MaxConcurrentManifestReads); + LauncherContentVersion?[] contentVersions = await Task.WhenAll( + (manifestUrls ?? new List()).Select(url => ReadChildManifestIfAvailableAsync( + url, + parentContentName, + semaphore, + cancellationToken))).ConfigureAwait(false); + + IReadOnlyList successfulVersions = + contentVersions.Where(version => version != null).ToList()!; + return new RemoteChildManifestLoadResult( + successfulVersions, + contentVersions.Count(version => version is null)); + } + + /// + /// Downloads one modification manifest while respecting the startup refresh concurrency limit. + /// + private async Task DownloadModDataIfAvailableAsync( + RemoteCatalogModificationReference reference, + SemaphoreSlim semaphore, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(reference); + + return await ReadWithConcurrencyLimitIfAvailableAsync( + semaphore, + token => DownloadModDataAsync(reference, token), + exception => + _logger.LogWarning( + "Failed to download remote modification manifest for {ModificationName}: {FailureReason}.", + reference.Name, + exception.Message), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Reads one child manifest while respecting the startup refresh concurrency limit. + /// + private async Task ReadChildManifestIfAvailableAsync( + string url, + string? parentContentName, + SemaphoreSlim semaphore, + CancellationToken cancellationToken) + { + return await ReadWithConcurrencyLimitIfAvailableAsync( + semaphore, + token => ReadModificationYamlAsync(url, parentContentName, token), + exception => + _logger.LogWarning( + "Failed to read child modification manifest: {FailureReason}.", + exception.Message), + cancellationToken).ConfigureAwait(false); + } + + private static async Task ReadWithConcurrencyLimitIfAvailableAsync( + SemaphoreSlim semaphore, + Func> read, + Action logFailure, + CancellationToken cancellationToken) + where T : class + { + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await ReadIfAvailableAsync(read, logFailure, cancellationToken).ConfigureAwait(false); + } + finally + { + semaphore.Release(); + } + } + + private static async Task ReadIfAvailableAsync( + Func> read, + Action logFailure, + CancellationToken cancellationToken) + where T : class + { + try + { + return await read(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + logFailure(exception); + return null; + } + } + + public async Task DownloadAdvertisingInfoAsync( + string manifestUrl, + CancellationToken cancellationToken) + { + return await ReadIfAvailableAsync( + token => ReadModificationYamlAsync(manifestUrl, null, token), + exception => + _logger.LogWarning( + "Failed to download advertising manifest: {FailureReason}.", + exception.Message), + cancellationToken).ConfigureAwait(false); + } + + private async Task DownloadModDataAsync( + RemoteCatalogModificationReference reference, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(reference); + + LauncherContentVersion modification = await ReadModificationYamlAsync( + reference.ManifestUrl, + null, + cancellationToken).ConfigureAwait(false); + return new RemoteModificationManifest( + modification, + reference.PatchManifestUrls, + reference.AddonManifestUrls); + } + + private async Task ReadModificationYamlAsync( + string documentUrl, + string? parentContentName, + CancellationToken cancellationToken) + { + LegacyContentManifest manifest = await _yamlDocumentReader.ReadYamlAsync( + new Uri(documentUrl, UriKind.Absolute), + cancellationToken).ConfigureAwait(false); + return RemoteLauncherCatalogMapper.ToLauncherContentVersion(manifest, parentContentName); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/YamlLauncherContentStateStore.cs b/GenLauncherGO.Infrastructure/Mods/Services/YamlLauncherContentStateStore.cs new file mode 100644 index 00000000..a8c3599c --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/YamlLauncherContentStateStore.cs @@ -0,0 +1,49 @@ +using System; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Persistence.Services; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Stores launcher content state in a YAML-backed document. +/// +internal sealed class YamlLauncherContentStateStore : ILauncherContentStateStore +{ + private readonly IAtomicFileWriter _atomicFileWriter; + + private readonly ILogger> _logger; + + public YamlLauncherContentStateStore( + IAtomicFileWriter atomicFileWriter, + ILogger> logger) + { + _atomicFileWriter = atomicFileWriter ?? throw new ArgumentNullException(nameof(atomicFileWriter)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public LauncherContentState Load(LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + return CreateDocumentStore(paths).Load(new LauncherContentState()); + } + + public void Save(LauncherPaths paths, LauncherContentState state) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(state); + + CreateDocumentStore(paths).Save(state); + } + + private IYamlDocumentStore CreateDocumentStore(LauncherPaths paths) + { + return new YamlDocumentStore( + paths.LauncherDataFilePath, + _atomicFileWriter, + _logger); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Support/ModificationCacheFailure.cs b/GenLauncherGO.Infrastructure/Mods/Support/ModificationCacheFailure.cs new file mode 100644 index 00000000..acb12d97 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Support/ModificationCacheFailure.cs @@ -0,0 +1,16 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Infrastructure.Mods.Support; + +/// +/// Classifies expected filesystem and path failures handled by modification image and palette cache boundaries. +/// +internal static class ModificationCacheFailure +{ + public static bool IsRecoverable(Exception exception) + { + return exception is InvalidDataException or IOException or UnauthorizedAccessException + or ArgumentException or NotSupportedException; + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Support/ModificationImageCachePath.cs b/GenLauncherGO.Infrastructure/Mods/Support/ModificationImageCachePath.cs new file mode 100644 index 00000000..4949e329 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Support/ModificationImageCachePath.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Infrastructure.Mods.Support; + +/// +/// Owns safe cached-image paths and the file naming convention shared by catalog downloads and integrity repair. +/// +internal static class ModificationImageCachePath +{ + private const string PathSubject = "Cached modification image paths"; + + private const string ImageDirectoryOwnerDescription = "the launcher-owned image directory"; + + private const string CacheDirectoryOwnerDescription = "the modification image cache directory"; + + public static string ResolveDirectory( + LauncherPaths paths, + ModificationType modificationType, + string modificationName) + { + return ResolvePath(paths, GetDirectoryPath(paths, modificationType, modificationName)); + } + + /// + /// Builds the lexical cache path without traversing it, so cleanup can safely unlink a reparse-point entry. + /// + public static string GetDirectoryPath( + LauncherPaths paths, + ModificationType modificationType, + string modificationName) + { + ArgumentNullException.ThrowIfNull(paths); + + return paths.GetModificationImagesDirectory(GetDirectoryName(modificationType, modificationName)); + } + + public static string ResolvePath(LauncherPaths paths, string imagePath) + { + ArgumentNullException.ThrowIfNull(paths); + + return FileSystemPathSafety.ResolveOwnedSubpath( + paths.ImagesDirectory, + imagePath, + PathSubject, + ImageDirectoryOwnerDescription); + } + + public static string ResolveImagePath( + LauncherPaths paths, + ModificationType modificationType, + string modificationName, + string imageFileName) + { + ArgumentNullException.ThrowIfNull(paths); + + return ResolvePath( + paths, + paths.GetModificationImageFilePath( + GetDirectoryName(modificationType, modificationName), + imageFileName)); + } + + public static string ResolveRemoteImagePath( + LauncherPaths paths, + ModificationType modificationType, + string modificationName, + string imageBaseName, + Uri sourceUri) + { + ArgumentNullException.ThrowIfNull(paths); + + return ResolveImagePath( + paths, + modificationType, + modificationName, + GetRemoteImageFileName(imageBaseName, sourceUri)); + } + + public static string ResolveRemoteImagePath( + string cacheDirectory, + string imageBaseName, + Uri sourceUri) + { + ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory); + + return FileSystemPathSafety.ResolveOwnedSubpath( + cacheDirectory, + Path.Combine(cacheDirectory, GetRemoteImageFileName(imageBaseName, sourceUri)), + PathSubject, + CacheDirectoryOwnerDescription); + } + + /// + /// Enumerates the remote artwork published by one content version and the canonical base name of each asset. + /// + public static IReadOnlyList<(string Link, string BaseName, bool RequiresTheme)> GetRemoteAssetSources( + LauncherContentVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + var sources = new List<(string Link, string BaseName, bool RequiresTheme)>(2); + if (!string.IsNullOrEmpty(version.UIImageSourceLink)) + { + sources.Add((version.UIImageSourceLink, version.Version, false)); + } + + string backgroundLink = version.Theme?.GenLauncherBackgroundImageLink ?? string.Empty; + if (backgroundLink.Length > 0) + { + sources.Add(( + backgroundLink, + LauncherContentTheme.ResolveBackgroundImageBaseName(version.Version), + true)); + } + + return sources; + } + + /// + /// Resolves the valid remote artwork destinations expected inside one modification cache directory. + /// + public static IReadOnlyList<(Uri SourceUri, string DestinationPath)> ResolveRemoteAssets( + LauncherContentVersion version, + string cacheDirectory) + { + ArgumentNullException.ThrowIfNull(version); + ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory); + + var assets = new List<(Uri SourceUri, string DestinationPath)>(2); + foreach ((string link, string baseName, _) in GetRemoteAssetSources(version)) + { + if (Uri.TryCreate(link, UriKind.Absolute, out Uri? sourceUri)) + { + assets.Add((sourceUri, ResolveRemoteImagePath(cacheDirectory, baseName, sourceUri))); + } + } + + return assets; + } + + /// + /// Gets every cache-file base name owned by one version, including local palette data and optional artwork. + /// + public static IReadOnlyList GetOwnedBaseNames(LauncherContentVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + return + [ + version.Version, + LauncherContentTheme.ResolveBackgroundImageBaseName(version.Version), + LauncherContentTheme.ResolveCacheBaseName(version.Version) + ]; + } + + private static string GetRemoteImageFileName(string imageBaseName, Uri sourceUri) + { + ArgumentNullException.ThrowIfNull(sourceUri); + + string extension = Path.GetExtension(sourceUri.LocalPath); + if (!LauncherContentFileTypes.IsImage(extension)) + { + extension = LauncherContentFileTypes.DefaultImageExtension; + } + + return imageBaseName + extension; + } + + private static string GetDirectoryName(ModificationType modificationType, string modificationName) + { + return modificationType == ModificationType.Advertising + ? modificationName.Trim(Path.GetInvalidFileNameChars()) + : modificationName; + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs b/GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs new file mode 100644 index 00000000..38f71352 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs @@ -0,0 +1,151 @@ +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Support; + +/// +/// Maps third-party backend manifest DTOs once into normalized launcher models. +/// +internal static class RemoteLauncherCatalogMapper +{ + /// + /// Maps a backend repository manifest to a normalized remote catalog. + /// + public static RemoteLauncherCatalog ToRemoteCatalog(LegacyLauncherCatalogDocument? repositoryData) + { + if (repositoryData is null) + { + return RemoteLauncherCatalog.Empty; + } + + // globalAddonsData is a vestigial backend field that neither the predecessor nor this launcher exposes. + return new RemoteLauncherCatalog( + ToAdvertisingReferences(repositoryData.AdvData), + ToModificationReferences(repositoryData.modDatas), + ToStringList(repositoryData.originalGameAddons), + ToStringList(repositoryData.originalGamePatches)); + } + + /// + /// Maps a backend content manifest directly to normalized domain metadata. + /// + public static LauncherContentVersion ToLauncherContentVersion( + LegacyContentManifest? manifest, + string? parentContentName = null) + { + if (manifest is null) + { + return new LauncherContentVersion + { + ParentContentName = parentContentName ?? string.Empty + }; + } + + string simpleDownloadLink = manifest.SimpleDownloadLink ?? string.Empty; + string s3HostLink = manifest.S3HostLink ?? string.Empty; + string s3BucketName = manifest.S3BucketName ?? string.Empty; + string s3FolderName = manifest.S3FolderName ?? string.Empty; + var installation = new LauncherContentInstallation + { + ContentSourceKind = LauncherContentVersion.ResolveContentSourceKind( + s3HostLink, + s3BucketName, + s3FolderName, + simpleDownloadLink, + manifest.ContentSourceKind) + }; + + return new LauncherContentVersion(installation) + { + ModificationType = manifest.ModificationType, + Name = manifest.Name ?? string.Empty, + Version = manifest.Version ?? string.Empty, + SimpleDownloadLink = simpleDownloadLink, + UIImageSourceLink = manifest.UIImageSourceLink ?? string.Empty, + DiscordLink = manifest.DiscordLink ?? string.Empty, + ModDBLink = manifest.ModDBLink ?? string.Empty, + NewsLink = manifest.NewsLink ?? string.Empty, + ParentContentName = parentContentName ?? manifest.DependenceName ?? string.Empty, + S3HostLink = s3HostLink, + S3BucketName = s3BucketName, + S3FolderName = s3FolderName, + S3HostPublicKey = manifest.S3HostPublicKey ?? string.Empty, + S3HostSecretKey = manifest.S3HostSecretKey ?? string.Empty, + NetworkInfo = manifest.NetworkInfo ?? string.Empty, + Deprecated = manifest.Deprecated, + SupportLink = manifest.SupportLink ?? string.Empty, + Theme = ToLauncherContentTheme(manifest.ColorsInformation) + }; + } + + /// + /// Maps the backend's per-modification palette, dropping a block that carries nothing usable. + /// + /// + /// Slots stay independent on purpose: a modification may publish only the few colours it cares about, and the + /// launcher fills the rest from the active game's palette rather than rejecting the block. + /// + private static LauncherContentTheme? ToLauncherContentTheme(LegacyContentThemeManifest? theme) + { + if (theme is null) + { + return null; + } + + var mapped = new LauncherContentTheme + { + GenLauncherBorderColor = Normalize(theme.GenLauncherBorderColor), + GenLauncherInactiveBorder = Normalize(theme.GenLauncherInactiveBorder), + GenLauncherInactiveBorder2 = Normalize(theme.GenLauncherInactiveBorder2), + GenLauncherActiveColor = Normalize(theme.GenLauncherActiveColor), + GenLauncherDarkFillColor = Normalize(theme.GenLauncherDarkFillColor), + GenLauncherDarkBackGround = Normalize(theme.GenLauncherDarkBackGround), + GenLauncherLightBackGround = Normalize(theme.GenLauncherLightBackGround), + GenLauncherDefaultTextColor = Normalize(theme.GenLauncherDefaultTextColor), + GenLauncherDownloadTextColor = Normalize(theme.GenLauncherDownloadTextColor), + GenLauncherListBoxSelectionColor1 = Normalize(theme.GenLauncherListBoxSelectionColor1), + GenLauncherListBoxSelectionColor2 = Normalize(theme.GenLauncherListBoxSelectionColor2), + GenLauncherButtonSelectionColor = Normalize(theme.GenLauncherButtonSelectionColor), + GenLauncherBackgroundImageLink = Normalize(theme.GenLauncherBackgroundImageLink) + }; + + return mapped.HasValues ? mapped : null; + } + + private static string Normalize(string? value) + { + return value?.Trim() ?? string.Empty; + } + + private static IReadOnlyList ToModificationReferences( + IEnumerable? modificationReferences) + { + return (modificationReferences ?? []) + .Select(reference => new RemoteCatalogModificationReference( + reference.ModName, + reference.ModLink, + ToStringList(reference.ModPatches), + ToStringList(reference.ModAddons))) + .ToList(); + } + + private static IReadOnlyList ToAdvertisingReferences( + IEnumerable? advertisingReferences) + { + return (advertisingReferences ?? []) + .Select(reference => new RemoteAdvertisingReference( + reference.ModName, + reference.ModLink, + ToStringList(reference.ImagesData))) + .ToList(); + } + + private static IReadOnlyList ToStringList(IEnumerable? values) + { + return (values ?? []) + .OfType() + .ToList(); + } +} diff --git a/GenLauncherGO.Infrastructure/Persistence/Services/AtomicFileWriter.cs b/GenLauncherGO.Infrastructure/Persistence/Services/AtomicFileWriter.cs new file mode 100644 index 00000000..e5fb5410 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Persistence/Services/AtomicFileWriter.cs @@ -0,0 +1,193 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Infrastructure.Persistence.Services; + +/// +/// Writes complete text files through a same-directory temporary file and atomic commit. +/// +internal sealed class AtomicFileWriter : IAtomicFileWriter +{ + public void WriteText(string destinationPath, string contents) + { + ArgumentNullException.ThrowIfNull(contents); + (string fullDestinationPath, string temporaryPath) = PrepareWrite(destinationPath); + try + { + WriteTemporaryFile(temporaryPath, contents); + CommitTemporaryFile(temporaryPath, fullDestinationPath); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + public async Task WriteAsync( + string destinationPath, + Func writeTemporaryFileAsync, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(writeTemporaryFileAsync); + cancellationToken.ThrowIfCancellationRequested(); + (string fullDestinationPath, string temporaryPath) = PrepareWrite(destinationPath); + try + { + await WriteTemporaryFileAsync( + temporaryPath, + writeTemporaryFileAsync, + cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + // Once the atomic replace or move begins, it must run to completion so callers never observe + // an ambiguous destination state. + CommitTemporaryFile(temporaryPath, fullDestinationPath); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + public async Task WriteFileIfMissingAsync( + string destinationPath, + Func writeTemporaryFileAsync, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(writeTemporaryFileAsync); + cancellationToken.ThrowIfCancellationRequested(); + (string fullDestinationPath, string temporaryPath) = PrepareWrite(destinationPath); + if (File.Exists(fullDestinationPath)) + { + return false; + } + + try + { + await writeTemporaryFileAsync(temporaryPath, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return CommitTemporaryFileIfMissing(temporaryPath, fullDestinationPath); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + private static (string DestinationPath, string TemporaryPath) PrepareWrite(string destinationPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); + + string fullDestinationPath = LexicalPath.NormalizeFullPath(destinationPath); + string destinationDirectory = Path.GetDirectoryName(fullDestinationPath) + ?? throw new InvalidOperationException( + "Atomic document paths must have a parent directory."); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationDirectory, + "Atomic document directories"); + Directory.CreateDirectory(destinationDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationDirectory, + "Atomic document directories"); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + fullDestinationPath, + "Atomic document paths"); + + string temporaryPath = Path.Combine( + destinationDirectory, + $".{Path.GetFileName(fullDestinationPath)}.{Guid.NewGuid():N}.tmp"); + return (fullDestinationPath, temporaryPath); + } + + private static void WriteTemporaryFile(string temporaryPath, string contents) + { + byte[] bytes = new UTF8Encoding(false).GetBytes(contents); + using FileStream stream = new( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.WriteThrough); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + } + + private static async Task WriteTemporaryFileAsync( + string temporaryPath, + Func writeTemporaryFileAsync, + CancellationToken cancellationToken) + { + await using FileStream stream = new( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.Asynchronous | FileOptions.WriteThrough); + await writeTemporaryFileAsync(stream, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + // FlushAsync drains managed buffers with cancellation support. Flush(true) retains the existing + // durable-to-disk guarantee before the atomic commit. + stream.Flush(true); + } + + private static void CommitTemporaryFile(string temporaryPath, string destinationPath) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationPath, + "Atomic document paths"); + if (File.Exists(destinationPath)) + { + File.Replace(temporaryPath, destinationPath, null, true); + return; + } + + try + { + File.Move(temporaryPath, destinationPath); + } + catch (IOException) when (File.Exists(destinationPath)) + { + File.Replace(temporaryPath, destinationPath, null, true); + } + } + + private static bool CommitTemporaryFileIfMissing(string temporaryPath, string destinationPath) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationPath, + "Atomic file paths"); + if (File.Exists(destinationPath)) + { + return false; + } + + try + { + File.Move(temporaryPath, destinationPath); + return true; + } + catch (IOException) when (File.Exists(destinationPath)) + { + return false; + } + } +} diff --git a/GenLauncherGO.Infrastructure/Persistence/Services/IAtomicFileWriter.cs b/GenLauncherGO.Infrastructure/Persistence/Services/IAtomicFileWriter.cs new file mode 100644 index 00000000..89f8c02a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Persistence/Services/IAtomicFileWriter.cs @@ -0,0 +1,41 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Persistence.Services; + +/// +/// Commits complete files atomically within their destination directory. +/// +internal interface IAtomicFileWriter +{ + /// + /// Writes and durably flushes a temporary file before atomically committing it to the destination path. + /// + void WriteText(string destinationPath, string contents); + + /// + /// Writes and durably flushes a temporary file asynchronously before atomically committing it to the destination path. + /// + /// The final document path. + /// + /// The operation that writes the complete document to the temporary stream and leaves the stream open. + /// + /// + /// A token that cancels temporary-file writing and flushing. The final atomic commit is not cancellable once started. + /// + Task WriteAsync( + string destinationPath, + Func writeTemporaryFileAsync, + CancellationToken cancellationToken); + + /// + /// Writes to a temporary sibling path and atomically publishes it only when the destination is still missing. + /// + /// when this call published the file; otherwise, . + Task WriteFileIfMissingAsync( + string destinationPath, + Func writeTemporaryFileAsync, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Persistence/Services/IYamlDocumentStore.cs b/GenLauncherGO.Infrastructure/Persistence/Services/IYamlDocumentStore.cs new file mode 100644 index 00000000..85743860 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Persistence/Services/IYamlDocumentStore.cs @@ -0,0 +1,19 @@ +namespace GenLauncherGO.Infrastructure.Persistence.Services; + +internal interface IYamlDocumentStore + where TDocument : class +{ + bool DocumentExists { get; } + + /// + /// Loads the document from disk. + /// + /// The loaded document, or . + TDocument Load(TDocument defaultDocument); + + /// + /// Saves the document to disk. + /// + /// Persistence failures are logged and propagated to the caller. + 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..50058195 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Persistence/Services/YamlDocumentStore.cs @@ -0,0 +1,83 @@ +using System; +using System.IO; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; +using YamlDotNet.Serialization; + +namespace GenLauncherGO.Infrastructure.Persistence.Services; + +internal sealed class YamlDocumentStore : IYamlDocumentStore + where TDocument : class +{ + private readonly IAtomicFileWriter _atomicFileWriter; + private readonly string _documentFilePath; + + private readonly ILogger> _logger; + + public YamlDocumentStore( + string documentFilePath, + IAtomicFileWriter atomicFileWriter, + ILogger> logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(documentFilePath); + + _documentFilePath = documentFilePath; + _atomicFileWriter = atomicFileWriter ?? throw new ArgumentNullException(nameof(atomicFileWriter)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public bool DocumentExists => File.Exists(_documentFilePath); + + public TDocument Load(TDocument defaultDocument) + { + ArgumentNullException.ThrowIfNull(defaultDocument); + + if (!File.Exists(_documentFilePath)) + { + return defaultDocument; + } + + try + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + _documentFilePath, + "YAML document paths"); + IDeserializer deserializer = new DeserializerBuilder() + .IgnoreUnmatchedProperties() + .Build(); + + using TextReader reader = File.OpenText(_documentFilePath); + return deserializer.Deserialize(reader) ?? defaultDocument; + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to load {DocumentType} from {DocumentFileName}.", + typeof(TDocument).Name, + Path.GetFileName(_documentFilePath)); + return defaultDocument; + } + } + + public void Save(TDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + try + { + ISerializer serializer = new Serializer(); + string yaml = serializer.Serialize(document); + _atomicFileWriter.WriteText(_documentFilePath, yaml); + } + catch (Exception exception) + { + _logger.LogError( + exception, + "Failed to save {DocumentType} to {DocumentFileName}.", + typeof(TDocument).Name, + Path.GetFileName(_documentFilePath)); + throw; + } + } +} diff --git a/GenLauncherGO.Infrastructure/Properties/AssemblyInfo.cs b/GenLauncherGO.Infrastructure/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..33f07f82 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Properties/AssemblyInfo.cs @@ -0,0 +1,9 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[assembly: InternalsVisibleTo("GenLauncherGO.Tests")] + +// Every P/Invoke in this assembly targets kernel32 or user32, so restricting +// resolution to System32 cannot fail to find them, and it removes the search of +// the application directory that a planted DLL of the same name would exploit. +[assembly: DefaultDllImportSearchPaths(DllImportSearchPath.System32)] diff --git a/GenLauncherGO.Infrastructure/Remote/Contracts/IRemoteAssetDownloader.cs b/GenLauncherGO.Infrastructure/Remote/Contracts/IRemoteAssetDownloader.cs new file mode 100644 index 00000000..04a179e1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/Contracts/IRemoteAssetDownloader.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Remote.Contracts; + +internal interface IRemoteAssetDownloader +{ + /// + /// Downloads an asset only when the destination file is not already present. + /// + Task DownloadIfMissingAsync( + Uri sourceUri, + string destinationFilePath, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Remote/Contracts/IRemoteYamlDocumentReader.cs b/GenLauncherGO.Infrastructure/Remote/Contracts/IRemoteYamlDocumentReader.cs new file mode 100644 index 00000000..ceda8f6a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/Contracts/IRemoteYamlDocumentReader.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Remote.Contracts; + +internal interface IRemoteYamlDocumentReader +{ + Task ReadYamlAsync(Uri documentUri, CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Remote/HttpHeadFallbackRequest.cs b/GenLauncherGO.Infrastructure/Remote/HttpHeadFallbackRequest.cs new file mode 100644 index 00000000..17946d16 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/HttpHeadFallbackRequest.cs @@ -0,0 +1,66 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Remote; + +/// +/// Sends a headers-only HEAD request and falls back to GET when the endpoint or response reader requires it. +/// +internal static class HttpHeadFallbackRequest +{ + public static async Task SendAsync( + HttpClient httpClient, + Uri endpointUri, + Func readResponse, + Func shouldFallback, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(endpointUri); + ArgumentNullException.ThrowIfNull(readResponse); + ArgumentNullException.ThrowIfNull(shouldFallback); + + (TResult HeadResult, bool HeadUnsupported) head = await SendAsync( + httpClient, + endpointUri, + HttpMethod.Head, + readResponse, + cancellationToken).ConfigureAwait(false); + if (!head.HeadUnsupported && !shouldFallback(head.HeadResult)) + { + return head.HeadResult; + } + + (TResult getResult, _) = await SendAsync( + httpClient, + endpointUri, + HttpMethod.Get, + readResponse, + cancellationToken).ConfigureAwait(false); + return getResult; + } + + private static async Task<(TResult Result, bool HeadUnsupported)> SendAsync( + HttpClient httpClient, + Uri endpointUri, + HttpMethod method, + Func readResponse, + CancellationToken cancellationToken) + { + using HttpRequestMessage request = new(method, endpointUri); + using HttpResponseMessage response = await httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + bool headUnsupported = method == HttpMethod.Head && + response.StatusCode is HttpStatusCode.MethodNotAllowed or + HttpStatusCode.NotImplemented; + return headUnsupported + ? (default!, true) + : (readResponse(response), false); + } +} diff --git a/GenLauncherGO.Infrastructure/Remote/HttpRemoteAssetDownloader.cs b/GenLauncherGO.Infrastructure/Remote/HttpRemoteAssetDownloader.cs new file mode 100644 index 00000000..bf94440a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/HttpRemoteAssetDownloader.cs @@ -0,0 +1,69 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Infrastructure.Remote.Contracts; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Remote; + +internal sealed class HttpRemoteAssetDownloader : IRemoteAssetDownloader +{ + private readonly IResumableFileDownloader _fileDownloader; + private readonly IAtomicFileWriter _atomicFileWriter; + private readonly ILogger _logger; + + public HttpRemoteAssetDownloader( + IResumableFileDownloader fileDownloader, + IAtomicFileWriter atomicFileWriter, + ILogger logger) + { + _fileDownloader = fileDownloader ?? throw new ArgumentNullException(nameof(fileDownloader)); + _atomicFileWriter = atomicFileWriter ?? throw new ArgumentNullException(nameof(atomicFileWriter)); + _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. + /// + public async Task DownloadIfMissingAsync( + Uri sourceUri, + string destinationFilePath, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(sourceUri); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationFilePath); + + if (File.Exists(destinationFilePath)) + { + return; + } + + string legacyTemporaryFilePath = destinationFilePath + ".download"; + if (File.Exists(legacyTemporaryFilePath)) + { + File.Delete(legacyTemporaryFilePath); + _logger.LogDebug( + "Deleted stale remote asset download file {FileName}.", + Path.GetFileName(legacyTemporaryFilePath)); + } + + bool committed = await _atomicFileWriter.WriteFileIfMissingAsync( + destinationFilePath, + (temporaryFilePath, token) => _fileDownloader.DownloadFileAsync( + new DownloadFileRequest(sourceUri, temporaryFilePath, Resume: false), + null, + token), + cancellationToken).ConfigureAwait(false); + if (committed) + { + _logger.LogDebug( + "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..0c4d7f02 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/HttpRemoteConnectionProbe.cs @@ -0,0 +1,65 @@ +using System; +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. +/// +internal sealed class HttpRemoteConnectionProbe : IRemoteConnectionProbe +{ + private static readonly HttpClient _sharedHttpClient = + SharedHttpClientFactory.Create(TimeSpan.FromSeconds(30)); + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + 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. + /// + public async Task CanConnectAsync(Uri endpointUri, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(endpointUri); + + try + { + return await HttpHeadFallbackRequest.SendAsync( + _httpClient, + endpointUri, + static response => response.IsSuccessStatusCode, + static connected => !connected, + cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException exception) + { + _logger.LogWarning( + exception, + "Remote connection probe failed for {Scheme}://{Host}.", + endpointUri.Scheme, + endpointUri.Host); + return false; + } + catch (TaskCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning( + exception, + "Remote connection probe timed out for {Scheme}://{Host}.", + endpointUri.Scheme, + endpointUri.Host); + return false; + } + } + +} diff --git a/GenLauncherGO.Infrastructure/Remote/HttpRemoteYamlDocumentReader.cs b/GenLauncherGO.Infrastructure/Remote/HttpRemoteYamlDocumentReader.cs new file mode 100644 index 00000000..d6f481c4 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/HttpRemoteYamlDocumentReader.cs @@ -0,0 +1,70 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Remote.Contracts; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using YamlDotNet.Serialization; + +namespace GenLauncherGO.Infrastructure.Remote; + +/// +/// Reads YAML documents over HTTP. +/// +internal 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; + + 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 exception) + { + _logger.LogDebug( + exception, + "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..9796cf2a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/SharedHttpClientFactory.cs @@ -0,0 +1,34 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; + +namespace GenLauncherGO.Infrastructure.Remote; + +internal static class SharedHttpClientFactory +{ + /// + /// Creates an HTTP client with pooled connections, no automatic decompression, and a GenLauncherGO user agent. + /// + 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..d63b73e6 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Composition/SettingsInfrastructureServiceCollectionExtensions.cs @@ -0,0 +1,40 @@ +using System; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Infrastructure.Settings.Models; +using GenLauncherGO.Infrastructure.Settings.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Settings.Composition; + +public static class SettingsInfrastructureServiceCollectionExtensions +{ + public static IServiceCollection AddGenLauncherGoSettingsInfrastructure( + this IServiceCollection services, + string preferencesFilePath) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(preferencesFilePath); + + services.TryAddSingleton(); + services.AddSingleton>(serviceProvider => + new YamlDocumentStore( + preferencesFilePath, + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService>>())); + services.AddSingleton>(serviceProvider => + new YamlDocumentStore( + preferencesFilePath, + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService>>())); + services.AddSingleton>(serviceProvider => + new YamlDocumentStore( + preferencesFilePath, + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService>>())); + services.AddSingleton(); + return services; + } +} diff --git a/GenLauncherGO.Infrastructure/Settings/Models/LauncherPreferencesDocument.cs b/GenLauncherGO.Infrastructure/Settings/Models/LauncherPreferencesDocument.cs new file mode 100644 index 00000000..88bdfc08 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Models/LauncherPreferencesDocument.cs @@ -0,0 +1,117 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Infrastructure.Settings.Models; + +/// +/// Reads only the schema marker so unsupported documents can be rejected before binding their version-specific shape. +/// +internal sealed class LauncherPreferencesSchemaDocument +{ + /// + /// Tracks whether YAML binding encountered the schema key. The load fallback explicitly assigns + /// , + /// so distinguishes an unreadable document from valid legacy YAML that omits the key. + /// + public int? SchemaVersion + { + get; + set + { + field = value; + HasSchemaVersion = true; + } + } + + internal bool HasSchemaVersion { get; private set; } +} + +/// +/// Defines the exact standalone preferences YAML schema at the persistence boundary. +/// +internal sealed class LauncherPreferencesDocument +{ + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; set; } + + public LauncherInstallationsDocument? Installations { get; set; } + + public SupportedGame? LastSelectedGame { get; set; } + + public LauncherSharedPreferencesDocument? Shared { get; set; } + + public LauncherGamePreferencesSetDocument? Games { get; set; } +} + +/// +/// Defines the unversioned flat preferences format written before the standalone schema was introduced. +/// +internal sealed class LegacyLauncherPreferencesDocument +{ + public int? LaunchesCount { get; set; } + + public bool? AutoDeleteOldVersions { get; set; } + + public string? SelectedGameClient { get; set; } + + public bool HasKnownValues => + LaunchesCount.HasValue || + AutoDeleteOldVersions.HasValue || + SelectedGameClient is not null; +} + +internal sealed class LauncherInstallationsDocument +{ + public string? Generals { get; set; } + + public string? ZeroHour { get; set; } +} + +internal sealed class LauncherSharedPreferencesDocument +{ + public bool AutoDeleteOldVersions { get; set; } + + public bool HideLauncherAfterGameStart { get; set; } + + public bool EnableDiagnosticLogging { get; set; } + + public bool UseEnglishLanguage { get; set; } + + public bool HasShownRetailGenPatcherRecommendation { get; set; } +} + +internal sealed class LauncherGamePreferencesSetDocument +{ + public LauncherGamePreferencesDocument? Generals { get; set; } + + public LauncherGamePreferencesDocument? ZeroHour { get; set; } +} + +internal sealed class LauncherGamePreferencesDocument +{ + public int LaunchesCount { get; set; } + + public string? SelectedGameClient { get; set; } + + public string? SelectedWorldBuilder { get; set; } + + public string? GameArguments { get; set; } + + public string? WorldBuilderArguments { get; set; } + + public double ModsListVerticalOffset { get; set; } + + public int AdvertisingPositionInList { get; set; } + + public List? CustomGameClients { get; set; } + + public List? CustomWorldBuilders { get; set; } +} + +internal sealed class LauncherCustomExecutableDocument +{ + public string? DisplayName { get; set; } + + public string? ExecutableName { get; set; } +} diff --git a/GenLauncherGO.Infrastructure/Settings/Services/PreferencesService.cs b/GenLauncherGO.Infrastructure/Settings/Services/PreferencesService.cs new file mode 100644 index 00000000..13c5d97c --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Services/PreferencesService.cs @@ -0,0 +1,113 @@ +using System; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Core.Settings.Exceptions; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Infrastructure.Settings.Models; +using GenLauncherGO.Infrastructure.Settings.Support; + +namespace GenLauncherGO.Infrastructure.Settings.Services; + +/// +/// Persists launcher preferences as a standalone YAML document. +/// +internal sealed class PreferencesService : ILauncherPreferencesService +{ + private readonly IYamlDocumentStore _documentStore; + + private readonly IYamlDocumentStore _legacyDocumentStore; + private readonly IYamlDocumentStore _schemaDocumentStore; + + public PreferencesService( + IYamlDocumentStore schemaDocumentStore, + IYamlDocumentStore documentStore, + IYamlDocumentStore legacyDocumentStore) + { + _schemaDocumentStore = schemaDocumentStore ?? throw new ArgumentNullException(nameof(schemaDocumentStore)); + _documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore)); + _legacyDocumentStore = legacyDocumentStore ?? throw new ArgumentNullException(nameof(legacyDocumentStore)); + Current = LoadPreferences(); + } + + public event EventHandler? PreferencesChanged; + + public LauncherPreferences Current { get; private set; } + + public void Update(LauncherPreferences preferences) + { + ArgumentNullException.ThrowIfNull(preferences); + + LauncherPreferences normalizedPreferences = LauncherPreferencesDocumentMapper.Normalize(preferences); + if (normalizedPreferences == Current) + { + return; + } + + SavePreferences(normalizedPreferences); + + Current = normalizedPreferences; + PreferencesChanged?.Invoke(this, Current); + } + + private LauncherPreferences LoadPreferences() + { + if (!_schemaDocumentStore.DocumentExists) + { + return new LauncherPreferences(); + } + + LauncherPreferencesSchemaDocument schemaDocument = _schemaDocumentStore.Load( + new LauncherPreferencesSchemaDocument { SchemaVersion = null }); + if (!schemaDocument.HasSchemaVersion || schemaDocument.SchemaVersion == 0) + { + return LoadLegacyPreferences(); + } + + if (schemaDocument.SchemaVersion != LauncherPreferencesDocument.CurrentSchemaVersion) + { + return ResetPreferences(); + } + + LauncherPreferencesDocument document = _documentStore.Load(new LauncherPreferencesDocument()); + return document.SchemaVersion == LauncherPreferencesDocument.CurrentSchemaVersion + ? LauncherPreferencesDocumentMapper.ToPreferences(document) + : ResetPreferences(); + } + + private LauncherPreferences LoadLegacyPreferences() + { + LegacyLauncherPreferencesDocument legacyDocument = + _legacyDocumentStore.Load(new LegacyLauncherPreferencesDocument()); + if (!legacyDocument.HasKnownValues) + { + return ResetPreferences(); + } + + LauncherPreferences migratedPreferences = + LauncherPreferencesDocumentMapper.MigrateLegacyPreferences(legacyDocument); + return PersistLoadedPreferences(migratedPreferences); + } + + private LauncherPreferences ResetPreferences() + { + return PersistLoadedPreferences(new LauncherPreferences()); + } + + private LauncherPreferences PersistLoadedPreferences(LauncherPreferences preferences) + { + SavePreferences(preferences); + return preferences; + } + + private void SavePreferences(LauncherPreferences preferences) + { + try + { + _documentStore.Save(LauncherPreferencesDocumentMapper.ToDocument(preferences)); + } + catch (Exception exception) + { + throw new LauncherPreferencesPersistenceException(exception); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Settings/Support/LauncherPreferencesDocumentMapper.cs b/GenLauncherGO.Infrastructure/Settings/Support/LauncherPreferencesDocumentMapper.cs new file mode 100644 index 00000000..56e110f2 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Support/LauncherPreferencesDocumentMapper.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Settings.Models; + +namespace GenLauncherGO.Infrastructure.Settings.Support; + +/// +/// Maps the standalone preferences persistence schema once into normalized Core preferences. +/// +internal static class LauncherPreferencesDocumentMapper +{ + public static LauncherPreferences ToPreferences(LauncherPreferencesDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + if (document.SchemaVersion != LauncherPreferencesDocument.CurrentSchemaVersion) + { + throw new NotSupportedException( + $"Launcher preferences schema version {document.SchemaVersion} is not supported."); + } + + LauncherInstallationsDocument installations = document.Installations ?? new LauncherInstallationsDocument(); + LauncherGamePreferencesSetDocument games = document.Games ?? new LauncherGamePreferencesSetDocument(); + + return Normalize(new LauncherPreferences + { + Installations = new LauncherInstallations + { + Generals = installations.Generals, + ZeroHour = installations.ZeroHour + }, + LastSelectedGame = document.LastSelectedGame, + Shared = MapShared(document.Shared), + Games = new LauncherGamePreferencesSet + { + Generals = MapGame(games.Generals), + ZeroHour = MapGame(games.ZeroHour) + } + }); + } + + /// + /// Migrates the unversioned flat preferences format into the current normalized model. + /// + public static LauncherPreferences MigrateLegacyPreferences(LegacyLauncherPreferencesDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + return Normalize(new LauncherPreferences + { + Shared = new LauncherSharedPreferences + { + AutoDeleteOldVersions = document.AutoDeleteOldVersions ?? false + }, + Games = new LauncherGamePreferencesSet + { + ZeroHour = new LauncherGamePreferences + { + LaunchesCount = document.LaunchesCount ?? 0, + SelectedGameClient = document.SelectedGameClient ?? string.Empty + } + } + }); + } + + public static LauncherPreferencesDocument ToDocument(LauncherPreferences preferences) + { + LauncherPreferences normalized = Normalize(preferences); + + return new LauncherPreferencesDocument + { + SchemaVersion = LauncherPreferencesDocument.CurrentSchemaVersion, + Installations = new LauncherInstallationsDocument + { + Generals = normalized.Installations.Generals, + ZeroHour = normalized.Installations.ZeroHour + }, + LastSelectedGame = normalized.LastSelectedGame, + Shared = new LauncherSharedPreferencesDocument + { + AutoDeleteOldVersions = normalized.Shared.AutoDeleteOldVersions, + HideLauncherAfterGameStart = normalized.Shared.HideLauncherAfterGameStart, + EnableDiagnosticLogging = normalized.Shared.EnableDiagnosticLogging, + UseEnglishLanguage = normalized.Shared.UseEnglishLanguage, + HasShownRetailGenPatcherRecommendation = + normalized.Shared.HasShownRetailGenPatcherRecommendation + }, + Games = new LauncherGamePreferencesSetDocument + { + Generals = MapGame(normalized.Games.Generals), + ZeroHour = MapGame(normalized.Games.ZeroHour) + } + }; + } + + public static LauncherPreferences Normalize(LauncherPreferences preferences) + { + ArgumentNullException.ThrowIfNull(preferences); + + LauncherInstallations installations = preferences.Installations ?? new LauncherInstallations(); + LauncherSharedPreferences shared = preferences.Shared ?? new LauncherSharedPreferences(); + LauncherGamePreferencesSet games = preferences.Games ?? new LauncherGamePreferencesSet(); + + return new LauncherPreferences + { + Installations = new LauncherInstallations + { + Generals = NormalizePath(installations.Generals), + ZeroHour = NormalizePath(installations.ZeroHour) + }, + LastSelectedGame = NormalizeGame(preferences.LastSelectedGame), + Shared = shared, + Games = new LauncherGamePreferencesSet + { + Generals = NormalizeGamePreferences(games.Generals, SupportedGame.Generals), + ZeroHour = NormalizeGamePreferences(games.ZeroHour, SupportedGame.ZeroHour) + } + }; + } + + private static LauncherSharedPreferences MapShared(LauncherSharedPreferencesDocument? shared) + { + return shared is null + ? new LauncherSharedPreferences() + : new LauncherSharedPreferences + { + AutoDeleteOldVersions = shared.AutoDeleteOldVersions, + HideLauncherAfterGameStart = shared.HideLauncherAfterGameStart, + EnableDiagnosticLogging = shared.EnableDiagnosticLogging, + UseEnglishLanguage = shared.UseEnglishLanguage, + HasShownRetailGenPatcherRecommendation = shared.HasShownRetailGenPatcherRecommendation + }; + } + + private static LauncherGamePreferences MapGame(LauncherGamePreferencesDocument? game) + { + return game is null + ? new LauncherGamePreferences() + : new LauncherGamePreferences + { + LaunchesCount = game.LaunchesCount, + SelectedGameClient = game.SelectedGameClient ?? string.Empty, + SelectedWorldBuilder = game.SelectedWorldBuilder ?? string.Empty, + GameArguments = game.GameArguments ?? string.Empty, + WorldBuilderArguments = game.WorldBuilderArguments ?? string.Empty, + ModsListVerticalOffset = game.ModsListVerticalOffset, + AdvertisingPositionInList = game.AdvertisingPositionInList, + CustomGameClients = MapCustomExecutables(game.CustomGameClients), + CustomWorldBuilders = MapCustomExecutables(game.CustomWorldBuilders) + }; + } + + private static LauncherGamePreferencesDocument MapGame(LauncherGamePreferences game) + { + return new LauncherGamePreferencesDocument + { + LaunchesCount = game.LaunchesCount, + SelectedGameClient = game.SelectedGameClient, + SelectedWorldBuilder = game.SelectedWorldBuilder, + GameArguments = game.GameArguments, + WorldBuilderArguments = game.WorldBuilderArguments, + ModsListVerticalOffset = game.ModsListVerticalOffset, + AdvertisingPositionInList = game.AdvertisingPositionInList, + CustomGameClients = MapCustomExecutables(game.CustomGameClients), + CustomWorldBuilders = MapCustomExecutables(game.CustomWorldBuilders) + }; + } + + private static LauncherGamePreferences NormalizeGamePreferences( + LauncherGamePreferences? preferences, + SupportedGame game) + { + if (preferences is null) + { + return new LauncherGamePreferences(); + } + + return preferences with + { + LaunchesCount = Math.Max(0, preferences.LaunchesCount), + SelectedGameClient = (preferences.SelectedGameClient ?? string.Empty).Trim(), + SelectedWorldBuilder = (preferences.SelectedWorldBuilder ?? string.Empty).Trim(), + GameArguments = preferences.GameArguments ?? string.Empty, + WorldBuilderArguments = preferences.WorldBuilderArguments ?? string.Empty, + ModsListVerticalOffset = double.IsFinite(preferences.ModsListVerticalOffset) + ? Math.Max(0, preferences.ModsListVerticalOffset) + : 0, + AdvertisingPositionInList = Math.Max(0, preferences.AdvertisingPositionInList), + CustomGameClients = NormalizeCustomExecutables( + preferences.CustomGameClients, + LauncherFileSystemLayout.GetBuiltInGameExecutableNames(game)), + CustomWorldBuilders = NormalizeCustomExecutables( + preferences.CustomWorldBuilders, + LauncherFileSystemLayout.GetBuiltInWorldBuilderExecutableNames(game)) + }; + } + + private static IReadOnlyList MapCustomExecutables( + IReadOnlyList? documents) + { + if (documents == null || documents.Count == 0) + { + return Array.Empty(); + } + + var executables = new List(documents.Count); + foreach (LauncherCustomExecutableDocument document in documents) + { + if (document == null) + { + continue; + } + + try + { + executables.Add(new LauncherCustomExecutable( + document.DisplayName ?? string.Empty, + document.ExecutableName ?? string.Empty)); + } + catch (ArgumentException) + { + // Invalid persisted custom entries are ignored at the settings boundary. + } + } + + return executables; + } + + private static List MapCustomExecutables( + IReadOnlyList executables) + { + return executables + .Select(executable => new LauncherCustomExecutableDocument + { + DisplayName = executable.DisplayName, + ExecutableName = executable.ExecutableName + }) + .ToList(); + } + + private static IReadOnlyList NormalizeCustomExecutables( + IReadOnlyList? executables, + IEnumerable builtInNames) + { + if (executables == null || executables.Count == 0) + { + return Array.Empty(); + } + + var displayNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var executableNames = new HashSet(builtInNames, StringComparer.OrdinalIgnoreCase); + var normalized = new List(executables.Count); + bool changed = false; + + foreach (LauncherCustomExecutable? executable in executables) + { + if (executable == null || + !displayNames.Add(executable.DisplayName) || + !executableNames.Add(executable.ExecutableName)) + { + changed = true; + continue; + } + + normalized.Add(executable); + } + + return changed ? normalized : executables; + } + + private static SupportedGame? NormalizeGame(SupportedGame? game) + { + return game is SupportedGame.Generals or SupportedGame.ZeroHour ? game : null; + } + + private static string? NormalizePath(string? path) + { + if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path.Trim())) + { + return null; + } + + try + { + return LexicalPath.NormalizeFullPath(path.Trim()); + } + catch (Exception exception) when (exception is ArgumentException or IOException or NotSupportedException) + { + return null; + } + } +} diff --git a/GenLauncherGO.Infrastructure/Shell/Services/WindowsLauncherShellService.cs b/GenLauncherGO.Infrastructure/Shell/Services/WindowsLauncherShellService.cs new file mode 100644 index 00000000..6ca4ba52 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Shell/Services/WindowsLauncherShellService.cs @@ -0,0 +1,159 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Security; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Shell.Contracts; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Shell.Services; + +/// +/// Opens external targets through the Windows shell. +/// +internal sealed class WindowsLauncherShellService : ILauncherShellService +{ + private readonly ILogger _logger; + + private readonly Action _openShellTarget; + + public WindowsLauncherShellService(ILogger? logger = null) + : this(logger, OpenShellTarget) + { + } + + internal WindowsLauncherShellService( + ILogger? logger, + Action openShellTarget) + { + _logger = logger ?? NullLogger.Instance; + _openShellTarget = openShellTarget ?? throw new ArgumentNullException(nameof(openShellTarget)); + } + + public void OpenUri(string uri) + { + if (string.IsNullOrWhiteSpace(uri)) + { + _logger.LogWarning("Could not open shell URI because the target is empty."); + return; + } + + if (!Uri.TryCreate(uri, UriKind.Absolute, out Uri? parsedUri)) + { + _logger.LogWarning("Could not open shell URI because the target is not absolute."); + return; + } + + if (!string.Equals(parsedUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + && !string.Equals(parsedUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "Could not open shell URI because scheme {Scheme} is unsupported.", + parsedUri.Scheme); + return; + } + + OpenShellTarget(parsedUri.AbsoluteUri, GetUriLogTarget(parsedUri)); + } + + public void OpenFolder( + string folderPath, + bool requireFiles = false, + bool createIfMissing = false) + { + if (string.IsNullOrWhiteSpace(folderPath)) + { + _logger.LogWarning("Could not open shell folder because the target is empty."); + return; + } + + string fullPath; + try + { + fullPath = LexicalPath.NormalizeFullPath(folderPath); + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException + or PathTooLongException) + { + _logger.LogWarning(exception, "Could not normalize the shell folder target."); + return; + } + + if (!Directory.Exists(fullPath) && createIfMissing) + { + try + { + Directory.CreateDirectory(fullPath); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException + or SecurityException) + { + _logger.LogWarning( + exception, + "Could not create shell folder target {Target}.", + GetFolderLogTarget(fullPath)); + + return; + } + } + + if (!Directory.Exists(fullPath)) + { + _logger.LogWarning( + "Could not open shell folder {Target} because it does not exist.", + GetFolderLogTarget(fullPath)); + return; + } + + if (requireFiles && !Directory.EnumerateFiles(fullPath).Any()) + { + _logger.LogWarning( + "Could not open shell folder {Target} because it does not contain files.", + GetFolderLogTarget(fullPath)); + return; + } + + OpenShellTarget(fullPath, GetFolderLogTarget(fullPath)); + } + + private void OpenShellTarget(string target, string logTarget) + { + try + { + _openShellTarget(target); + } + catch (Exception exception) when (exception is Win32Exception or InvalidOperationException or IOException) + { + _logger.LogWarning( + exception, + "Could not open shell target {Target}.", + logTarget); + } + } + + private static string GetUriLogTarget(Uri uri) + { + return string.IsNullOrWhiteSpace(uri.Host) + ? uri.Scheme + : uri.Host; + } + + private static string GetFolderLogTarget(string fullPath) + { + string folderName = Path.GetFileName(Path.TrimEndingDirectorySeparator(fullPath)); + return string.IsNullOrWhiteSpace(folderName) + ? "folder" + : folderName; + } + + [ExcludeFromCodeCoverage(Justification = + "Calls the host shell; shell-open behavior is covered through the injected adapter.")] + private static void OpenShellTarget(string target) + { + Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); + } +} diff --git a/GenLauncherGO.Infrastructure/Startup/FileSystemLauncherPathResolver.cs b/GenLauncherGO.Infrastructure/Startup/FileSystemLauncherPathResolver.cs new file mode 100644 index 00000000..51ad66c4 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/FileSystemLauncherPathResolver.cs @@ -0,0 +1,75 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Startup; + +/// +/// Resolves and safely prepares standalone launcher-owned paths. +/// +public sealed class FileSystemLauncherPathResolver : ILauncherPathResolver +{ + private readonly ILogger _logger; + + public FileSystemLauncherPathResolver() + : this(NullLogger.Instance) + { + } + + public FileSystemLauncherPathResolver(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public LauncherStoragePaths Resolve(string executableDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableDirectory); + + return new LauncherStoragePaths(executableDirectory); + } + + public void PrepareLauncherDirectories(LauncherStoragePaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + OwnedDirectoryTree.EnsureExists(paths.ExecutableDirectory, paths.DataDirectory); + OwnedDirectoryTree.EnsureExists(paths.DataDirectory, paths.LogsDirectory); + + _logger.LogDebug("Prepared shared standalone launcher directories."); + } + + public void PrepareGameDirectories(LauncherPaths paths, bool cleanTemporaryDirectory) + { + ArgumentNullException.ThrowIfNull(paths); + + string dataDirectory = Path.GetDirectoryName(paths.OwnedGameDataDirectory) + ?? throw new InvalidDataException( + "A per-game data directory must have an owning shared data directory."); + OwnedDirectoryTree.EnsureExists(dataDirectory, paths.OwnedGameDataDirectory); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, paths.RuntimeDirectory); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, paths.CacheDirectory); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, paths.ImagesDirectory); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, paths.ModsDirectory); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, paths.TempDirectory); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, paths.DeploymentDirectory); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, paths.IntegrityDirectory); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, paths.StateDirectory); + + if (cleanTemporaryDirectory) + { + // Staged packages are kept: a download the launcher suspended on close leaves its partial content + // here, and that content is exactly what lets the next session resume instead of starting over. + OwnedDirectoryTree.PrepareEmptyExcept( + paths.OwnedGameDataDirectory, + paths.TempDirectory, + paths.PackagesDirectory); + } + + _logger.LogDebug( + "Prepared isolated launcher directories for {SupportedGame}.", + paths.Game); + } +} diff --git a/GenLauncherGO.Infrastructure/Startup/IGameInstallationRegistry.cs b/GenLauncherGO.Infrastructure/Startup/IGameInstallationRegistry.cs new file mode 100644 index 00000000..c4caccf2 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/IGameInstallationRegistry.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Infrastructure.Startup; + +/// +/// Supplies untrusted Windows registry candidates in installation-source priority order. +/// +internal interface IGameInstallationRegistry +{ + IReadOnlyList ReadCandidates(SupportedGame game); +} diff --git a/GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationRegistry.cs b/GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationRegistry.cs new file mode 100644 index 00000000..47414d45 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationRegistry.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Startup; +using Microsoft.Win32; + +namespace GenLauncherGO.Infrastructure.Startup; + +/// +/// Reads the GeneralsGameCode installation registry contract in storefront priority order. +/// +internal sealed class WindowsGameInstallationRegistry : IGameInstallationRegistry +{ + private const string GeneralsKey = + @"SOFTWARE\Electronic Arts\EA Games\Generals"; + + private const string ZeroHourEaKey = + @"SOFTWARE\Electronic Arts\EA Games\Command and Conquer Generals Zero Hour"; + + private const string ZeroHourSteamKey = + @"SOFTWARE\Electronic Arts\EA Games\ZeroHour"; + + private const string FirstDecadeKey = + @"SOFTWARE\Electronic Arts\EA Games\Command and Conquer The First Decade"; + + private static readonly (string KeyName, string ValueName)[] _generalsProbes = + [ + // Windows value names are case-insensitive, but GeneralsGameCode declares these + // separately for the EA App and Steam. Preserve that external contract and order. + (GeneralsKey, "InstallPath"), + (FirstDecadeKey, "gr_folder"), + (GeneralsKey, "installPath") + ]; + + private static readonly (string KeyName, string ValueName)[] _zeroHourProbes = + [ + (ZeroHourEaKey, "InstallPath"), + (FirstDecadeKey, "zh_folder"), + (ZeroHourSteamKey, "installPath") + ]; + + private static readonly RegistryView[] _views = + [ + RegistryView.Registry32, + RegistryView.Registry64 + ]; + + private readonly Func _readValue; + + internal WindowsGameInstallationRegistry() + : this(ReadLocalMachineValue) + { + } + + internal WindowsGameInstallationRegistry( + Func readValue) + { + _readValue = readValue ?? throw new ArgumentNullException(nameof(readValue)); + } + + public IReadOnlyList ReadCandidates(SupportedGame game) + { + IReadOnlyList<(string KeyName, string ValueName)> probes = game switch + { + SupportedGame.Generals => _generalsProbes, + SupportedGame.ZeroHour => _zeroHourProbes, + _ => throw PerGame.Unsupported(game, nameof(game)) + }; + + var candidates = new List(); + foreach ((string keyName, string valueName) in probes) + { + foreach (RegistryView view in _views) + { + AddCandidate(_readValue(view, keyName, valueName), candidates); + } + } + + return candidates; + } + + private static string? ReadLocalMachineValue( + RegistryView view, + string keyName, + string valueName) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view); + using RegistryKey? key = baseKey.OpenSubKey(keyName, false); + return key?.GetValue(valueName, null, RegistryValueOptions.DoNotExpandEnvironmentNames) + as string; + } + catch (Exception exception) when ( + exception is IOException or SecurityException or UnauthorizedAccessException + or PlatformNotSupportedException) + { + // Registry candidates are optional. Every value that is read is validated against the filesystem later. + return null; + } + } + + private static void AddCandidate(string? rawCandidate, List candidates) + { + if (rawCandidate is null) + { + return; + } + + string candidate = NormalizeRegistryValue(rawCandidate); + if (!string.IsNullOrWhiteSpace(candidate) && + !candidates.Exists(existing => + LexicalPath.AreEquivalent(existing, candidate))) + { + candidates.Add(candidate); + } + } + + private static string NormalizeRegistryValue(string value) + { + string candidate = Environment.ExpandEnvironmentVariables(value.Trim().Trim('"')); + return Path.TrimEndingDirectorySeparator(candidate); + } +} diff --git a/GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationService.cs b/GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationService.cs new file mode 100644 index 00000000..d77df4e8 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationService.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Core.Startup.Models; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Startup; + +/// +/// Validates selected game directories and discovers candidates from the Windows registry. +/// +public sealed class WindowsGameInstallationService : IGameInstallationService +{ + // Zero Hour wins when one physical directory satisfies both games, so discovery never assigns the same + // installation to both titles and containing-installation detection stays deterministic. + private static readonly SupportedGame[] _gamesInDetectionPriorityOrder = + [ + SupportedGame.ZeroHour, + SupportedGame.Generals + ]; + + private readonly ILogger _logger; + + private readonly IGameInstallationRegistry _registry; + + public WindowsGameInstallationService() + : this( + new WindowsGameInstallationRegistry(), + NullLogger.Instance) + { + } + + public WindowsGameInstallationService(ILogger logger) + : this(new WindowsGameInstallationRegistry(), logger) + { + } + + internal WindowsGameInstallationService( + IGameInstallationRegistry registry, + ILogger logger) + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public GameInstallationLocation? FindContainingInstallation(string executableDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableDirectory); + + string canonicalExecutablePath = PhysicalDirectoryPath.ResolveExisting(executableDirectory); + for (DirectoryInfo? directory = new(canonicalExecutablePath); + directory is not null; + directory = directory.Parent) + { + foreach (SupportedGame game in _gamesInDetectionPriorityOrder) + { + if (HasRecognizedExecutable(game, directory.FullName)) + { + return new GameInstallationLocation(game, directory.FullName); + } + } + } + + return null; + } + + public GameInstallationValidationResult Validate( + SupportedGame game, + string? directory, + string executableDirectory) + { + PerGame.EnsureSupported(game, nameof(game)); + ArgumentException.ThrowIfNullOrWhiteSpace(executableDirectory); + + if (string.IsNullOrWhiteSpace(directory)) + { + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.PathMissing); + } + + string candidatePath; + try + { + string selectedDirectory = directory.Trim().Trim('"').Trim(); + if (string.IsNullOrWhiteSpace(selectedDirectory)) + { + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.PathMissing); + } + + if (!Path.IsPathFullyQualified(selectedDirectory)) + { + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.PathUnavailable); + } + + candidatePath = LexicalPath.NormalizeFullPath(selectedDirectory); + } + catch (Exception exception) when (exception is ArgumentException or IOException or NotSupportedException) + { + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.PathUnavailable); + } + + if (!Directory.Exists(candidatePath)) + { + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.DirectoryNotFound); + } + + try + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + candidatePath, + "Game installation paths"); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + executableDirectory, + "Launcher paths"); + } + catch (InvalidDataException) + { + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.UnsafeFileSystemPath); + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + _logger.LogWarning( + exception, + "A {SupportedGame} installation candidate path could not be safely inspected.", + game); + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.PathUnavailable); + } + + try + { + string canonicalGamePath = PhysicalDirectoryPath.ResolveExisting(candidatePath); + string canonicalExecutablePath = PhysicalDirectoryPath.ResolveExisting(executableDirectory); + if (LexicalPath.IsPathInDirectory(canonicalExecutablePath, canonicalGamePath)) + { + return GameInstallationValidationResult.Invalid( + GameInstallationValidationFailure.LauncherLocationOverlapsGame); + } + + string sharedDataPath = Path.Combine( + canonicalExecutablePath, + LauncherFileSystemLayout.LauncherDataFolderName); + if (LexicalPath.IsPathInDirectory(canonicalGamePath, sharedDataPath)) + { + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.UnsafeFileSystemPath); + } + + if (!HasBuiltInExecutable(game, canonicalGamePath)) + { + return GameInstallationValidationResult.Invalid( + GameInstallationValidationFailure.BuiltInExecutableNotFound); + } + + return GameInstallationValidationResult.Valid(canonicalGamePath); + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or NotSupportedException or + UnauthorizedAccessException or Win32Exception) + { + _logger.LogWarning( + exception, + "A {SupportedGame} installation candidate could not be safely inspected.", + game); + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.PathUnavailable); + } + } + + public LauncherInstallations DiscoverValidInstallations( + LauncherInstallations current, + string executableDirectory) + { + ArgumentNullException.ThrowIfNull(current); + ArgumentException.ThrowIfNullOrWhiteSpace(executableDirectory); + + LauncherInstallations discovered = current; + var occupiedPhysicalPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (SupportedGame game in _gamesInDetectionPriorityOrder) + { + string? configuredPath = current.GetPath(game); + GameInstallationValidationResult configuredResult = + Validate(game, configuredPath, executableDirectory); + if (configuredResult.IsValid) + { + occupiedPhysicalPaths.Add(configuredResult.CanonicalPath!); + continue; + } + + foreach (string candidate in _registry.ReadCandidates(game)) + { + GameInstallationValidationResult candidateResult = + Validate(game, candidate, executableDirectory); + if (!candidateResult.IsValid) + { + continue; + } + + if (!occupiedPhysicalPaths.Add(candidateResult.CanonicalPath!)) + { + continue; + } + + discovered = discovered.WithPath(game, candidateResult.CanonicalPath); + _logger.LogInformation("Discovered a valid {SupportedGame} installation.", game); + break; + } + } + + return discovered; + } + + private static bool HasBuiltInExecutable(SupportedGame game, string directory) + { + foreach (string executableName in LauncherFileSystemLayout.GetBuiltInGameExecutableNames(game)) + { + string executablePath = Path.Combine(directory, executableName); + if (File.Exists(executablePath) && !FileSystemPathSafety.IsReparsePoint(executablePath)) + { + return true; + } + } + + return false; + } + + private static bool HasRecognizedExecutable(SupportedGame game, string directory) + { + return game switch + { + SupportedGame.Generals => + File.Exists(Path.Combine( + directory, + LauncherFileSystemLayout.GeneralsCommunityExecutableFileName)), + SupportedGame.ZeroHour => + File.Exists(Path.Combine( + directory, + LauncherFileSystemLayout.ZeroHourCommunityExecutableFileName)) || + File.Exists(Path.Combine( + directory, + LauncherFileSystemLayout.GeneralsOnlineExecutableFileName)), + _ => false + }; + } +} diff --git a/GenLauncherGO.Infrastructure/Startup/WindowsLauncherHostEnvironmentService.cs b/GenLauncherGO.Infrastructure/Startup/WindowsLauncherHostEnvironmentService.cs new file mode 100644 index 00000000..f63dd045 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/WindowsLauncherHostEnvironmentService.cs @@ -0,0 +1,186 @@ +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.IO; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Core.Startup.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Startup; + +/// +/// Provides Windows process, elevation, single-instance, and foreground-window startup operations. +/// +public sealed class WindowsLauncherHostEnvironmentService : ILauncherHostEnvironmentService +{ + private const int SwRestore = 9; + + private readonly ILogger _logger; + private readonly Action _waitBeforeSingleInstanceRetry; + + public WindowsLauncherHostEnvironmentService() + : this(NullLogger.Instance) + { + } + + public WindowsLauncherHostEnvironmentService(ILogger logger) + : this(logger, Thread.Sleep) + { + } + + internal WindowsLauncherHostEnvironmentService( + ILogger logger, + Action waitBeforeSingleInstanceRetry) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _waitBeforeSingleInstanceRetry = waitBeforeSingleInstanceRetry ?? + throw new ArgumentNullException(nameof(waitBeforeSingleInstanceRetry)); + } + + 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 LauncherRestartResult TryRestartCurrentProcess() + { + try + { + string? executablePath = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule?.FileName; + if (string.IsNullOrWhiteSpace(executablePath)) + { + const string MissingExecutableMessage = "The launcher executable path could not be resolved."; + _logger.LogError(MissingExecutableMessage); + return LauncherRestartResult.Failure(MissingExecutableMessage); + } + + var process = Process.Start(new ProcessStartInfo + { + FileName = executablePath, + WorkingDirectory = Path.GetDirectoryName(executablePath) ?? AppContext.BaseDirectory, + UseShellExecute = true + }); + if (process == null) + { + const string StartFailureMessage = "Windows did not start the replacement launcher process."; + _logger.LogError(StartFailureMessage); + return LauncherRestartResult.Failure(StartFailureMessage); + } + + process.Dispose(); + _logger.LogInformation("Started a replacement launcher process for restart."); + return LauncherRestartResult.Success; + } + catch (Exception exception) + { + _logger.LogError(exception, "Could not start a replacement launcher process."); + return LauncherRestartResult.Failure(exception.Message); + } + } + + public ILauncherSingleInstanceGuard TryAcquireSingleInstance(string instanceName, TimeSpan retryDelay) + { + ArgumentException.ThrowIfNullOrWhiteSpace(instanceName); + ArgumentOutOfRangeException.ThrowIfLessThan(retryDelay, TimeSpan.Zero); + + Mutex mutex = new(true, instanceName, out bool createdNew); + if (createdNew) + { + return new MutexSingleInstanceGuard(mutex, true); + } + + mutex.Dispose(); + if (retryDelay > TimeSpan.Zero) + { + _waitBeforeSingleInstanceRetry(retryDelay); + } + + mutex = new Mutex(true, instanceName, out createdNew); + if (createdNew) + { + return new MutexSingleInstanceGuard(mutex, true); + } + + mutex.Dispose(); + return MutexSingleInstanceGuard.NotAcquired; + } + + private static bool IsPathInDirectoryWhenKnown(string path, string directory) + { + return !string.IsNullOrWhiteSpace(directory) && + LexicalPath.IsPathInDirectory(path, directory); + } + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool ShowWindowAsync(HandleRef hWnd, int nCmdShow); + + private sealed class MutexSingleInstanceGuard : ILauncherSingleInstanceGuard + { + public static readonly MutexSingleInstanceGuard NotAcquired = new(null, false); + + private readonly Mutex? _mutex; + + 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..6d27ffc3 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/HttpDownloadFileMetadataReader.cs @@ -0,0 +1,80 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Infrastructure.Remote; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Clients; + +/// +/// Reads downloadable file metadata over HTTP. +/// +internal sealed class HttpDownloadFileMetadataReader : IDownloadFileMetadataReader +{ + private static readonly HttpClient _sharedHttpClient = + SharedHttpClientFactory.Create(TimeSpan.FromSeconds(60)); + + private readonly HttpClient _httpClient; + + public HttpDownloadFileMetadataReader(HttpClient? httpClient = null) + { + _httpClient = httpClient ?? _sharedHttpClient; + } + + public async Task ReadMetadataAsync( + Uri downloadUri, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(downloadUri); + + DownloadFileMetadata? metadata = await HttpHeadFallbackRequest.SendAsync( + _httpClient, + downloadUri, + response => ReadMetadata(downloadUri, response), + static result => result is null, + cancellationToken).ConfigureAwait(false); + + return metadata ?? throw new InvalidOperationException( + "Download link is incorrect, please contact modification creator and try again later."); + } + + private static DownloadFileMetadata? ReadMetadata( + Uri downloadUri, + HttpResponseMessage response) + { + 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, + NormalizeFileName(fileName), + response.Content.Headers.ContentLength); + } + + private static string NormalizeFileName(string fileName) + { + try + { + return LexicalPath.NormalizePathSegment(fileName.Trim('"'), nameof(fileName)); + } + catch (ArgumentException exception) + { + throw new InvalidOperationException( + "Download link is incorrect, please contact modification creator and try again later.", + exception); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Clients/MinioClientFactory.cs b/GenLauncherGO.Infrastructure/Updating/Clients/MinioClientFactory.cs new file mode 100644 index 00000000..430b6538 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/MinioClientFactory.cs @@ -0,0 +1,46 @@ +using System; +using Minio; + +namespace GenLauncherGO.Infrastructure.Updating.Clients; + +internal static class MinioClientFactory +{ + /// + /// Creates an authenticated MinIO client; explicit endpoint URI schemes override the host-only SSL preference. + /// + 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..07615e7a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/MinioS3ObjectManifestReader.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using Microsoft.Extensions.Logging; +using Minio; +using Minio.DataModel; +using Minio.DataModel.Args; + +namespace GenLauncherGO.Infrastructure.Updating.Clients; + +/// +/// Reads S3-compatible object listings with MinIO. +/// +internal sealed class MinioS3ObjectManifestReader : IS3ObjectManifestReader +{ + private readonly Func> + _listObjects; + + private readonly ILogger _logger; + + public MinioS3ObjectManifestReader(ILogger logger) + : this(logger, ListObjectsAsync) + { + } + + internal MinioS3ObjectManifestReader( + ILogger logger, + Func> listObjects) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _listObjects = listObjects ?? throw new ArgumentNullException(nameof(listObjects)); + } + + /// + /// Reads an authenticated S3-compatible object listing, returning manifest entries with prefix-relative names. + /// + 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); + + List files = []; + await foreach (S3ObjectManifestItem item in _listObjects(request, cancellationToken) + .ConfigureAwait(false)) + { + files.Add(new RemoteFileManifestEntry( + StripPrefix(item.Key, request.Prefix), + NormalizeETag(item.ETag), + item.Size)); + } + + _logger.LogDebug( + "Read {FileCount} S3 manifest entries from bucket {BucketName}, prefix {Prefix}.", + files.Count, + request.BucketName, + request.Prefix); + return files; + } + + [ExcludeFromCodeCoverage(Justification = + "Wraps MinIO SDK network enumeration; behavior is covered through the injected listing adapter.")] + private static async IAsyncEnumerable ListObjectsAsync( + S3ObjectManifestRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + using IMinioClient client = MinioClientFactory.Create( + request.Endpoint, + request.AccessKey, + request.SecretKey, + request.UseSsl); + + ListObjectsArgs args = new ListObjectsArgs() + .WithBucket(request.BucketName) + .WithPrefix(request.Prefix) + .WithRecursive(true); + + await foreach (Item item in client.ListObjectsEnumAsync(args, cancellationToken) + .ConfigureAwait(false)) + { + yield return new S3ObjectManifestItem( + item.Key, + item.ETag, + item.Size); + } + } + + private static string StripPrefix(string key, string prefix) + { + string normalizedPrefix = prefix.TrimEnd('/') + "/"; + if (key.StartsWith(normalizedPrefix, StringComparison.Ordinal)) + { + return key[normalizedPrefix.Length..]; + } + + return key; + } + + private static string NormalizeETag(string eTag) + { + return eTag.Trim().Trim('"'); + } + + internal sealed record S3ObjectManifestItem( + string Key, + string ETag, + ulong Size); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Clients/ResumableHttpFileDownloader.cs b/GenLauncherGO.Infrastructure/Updating/Clients/ResumableHttpFileDownloader.cs new file mode 100644 index 00000000..86910ae3 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/ResumableHttpFileDownloader.cs @@ -0,0 +1,393 @@ +using System; +using System.Buffers; +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 GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Remote; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Updating.Clients; + +/// +/// Downloads files over HTTP using range requests, pooled buffers, retry backoff, and idle-transfer detection. +/// +internal sealed class ResumableHttpFileDownloader : IResumableFileDownloader +{ + private const int DefaultBufferSize = 1024 * 1024; + private const int DefaultMaxAttempts = 5; + + private static readonly HttpClient _sharedHttpClient = + SharedHttpClientFactory.Create(Timeout.InfiniteTimeSpan); + + private readonly int _bufferSize; + + private readonly HttpClient _httpClient; + private readonly TimeSpan _idleTimeout; + private readonly TimeSpan _initialRetryDelay; + private readonly ILogger _logger; + private readonly int _maxAttempts; + private readonly TimeSpan _progressReportInterval; + private readonly TimeProvider _timeProvider; + + public ResumableHttpFileDownloader( + HttpClient? httpClient = null, + ILogger? logger = null) + : this( + httpClient, + logger, + DefaultBufferSize, + DefaultMaxAttempts, + TimeSpan.FromSeconds(30), + TimeSpan.FromMilliseconds(100), + TimeSpan.FromSeconds(1)) + { + } + + internal ResumableHttpFileDownloader( + HttpClient? httpClient, + ILogger? logger, + int bufferSize, + int maxAttempts, + TimeSpan idleTimeout, + TimeSpan progressReportInterval, + TimeSpan initialRetryDelay, + TimeProvider? timeProvider = null) + { + _httpClient = httpClient ?? _sharedHttpClient; + _logger = logger ?? NullLogger.Instance; + _bufferSize = bufferSize; + _maxAttempts = maxAttempts; + _idleTimeout = idleTimeout; + _progressReportInterval = progressReportInterval; + _initialRetryDelay = initialRetryDelay; + _timeProvider = timeProvider ?? TimeProvider.System; + + if (_bufferSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(bufferSize), + "Download buffer size must be greater than zero."); + } + + if (_maxAttempts <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxAttempts), "Maximum attempts must be greater than zero."); + } + + if (_idleTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(idleTimeout), "Idle timeout must be greater than zero."); + } + + if (_progressReportInterval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(progressReportInterval), + "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 = LexicalPath.NormalizeFullPath(request.DestinationFilePath); + Directory.CreateDirectory(Path.GetDirectoryName(destinationFilePath) ?? "."); + + Exception? lastException = null; + for (int attempt = 1; attempt <= _maxAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await DownloadAttemptAsync( + request with { DestinationFilePath = destinationFilePath }, + progress, + cancellationToken).ConfigureAwait(false); + return; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) when (IsRetriable(exception) && attempt < _maxAttempts) + { + lastException = exception; + _logger.LogWarning( + "Download attempt {Attempt} failed for {FileName}; failure type: {FailureType}; retrying.", + attempt, + Path.GetFileName(destinationFilePath), + exception.GetType().Name); + + await Task.Delay(GetRetryDelay(attempt), cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (IsRetriable(exception)) + { + lastException = exception; + break; + } + } + + throw new IOException( + string.Format( + CultureInfo.InvariantCulture, + "Download failed after {0} attempts.", + _maxAttempts), + lastException); + } + + private async Task DownloadAttemptAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + long existingBytes = GetExistingBytes(request); + if (request.Resume && + request.ExpectedBytes.HasValue && + File.Exists(request.DestinationFilePath) && + new FileInfo(request.DestinationFilePath).Length == request.ExpectedBytes.Value) + { + ReportProgress(progress, request.ExpectedBytes, existingBytes); + return; + } + + 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); + + 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; + ReportProgress(progress, totalBytes, bytesDownloaded); + + await using FileStream destinationStream = new( + request.DestinationFilePath, + fileMode, + FileAccess.Write, + FileShare.Read, + _bufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + + await using Stream responseStream = await response.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + + bytesDownloaded = await CopyToFileAsync( + responseStream, + destinationStream, + totalBytes, + bytesDownloaded, + progress, + request.PauseController, + 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)); + } + } + + 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; + } + + 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; + } + + private static bool ResponseStartsAtExpectedByte( + HttpResponseMessage response, + long expectedStartByte) + { + return response.Content.Headers.ContentRange?.From == expectedStartByte; + } + + private async Task CopyToFileAsync( + Stream responseStream, + FileStream destinationStream, + long? totalBytes, + long bytesDownloaded, + IProgress? progress, + PackageDownloadPauseController? pauseController, + CancellationToken cancellationToken) + { + byte[] buffer = ArrayPool.Shared.Rent(_bufferSize); + long progressStartTimestamp = _timeProvider.GetTimestamp(); + TimeSpan lastReportElapsed = TimeSpan.Zero; + + try + { + using var idleCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + + while (true) + { + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + idleCancellation.CancelAfter(_idleTimeout); + + int bytesRead; + try + { + bytesRead = await responseStream + .ReadAsync(buffer.AsMemory(0, _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 PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + await destinationStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken) + .ConfigureAwait(false); + + bytesDownloaded += bytesRead; + TimeSpan elapsed = _timeProvider.GetElapsedTime(progressStartTimestamp); + if (elapsed - lastReportElapsed >= _progressReportInterval) + { + ReportProgress(progress, totalBytes, bytesDownloaded); + lastReportElapsed = elapsed; + } + } + + ReportProgress(progress, totalBytes, bytesDownloaded); + return bytesDownloaded; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private static bool IsRetriable(Exception exception) + { + return exception is HttpRequestException or IOException or TimeoutException or + TaskCanceledException; + } + + private TimeSpan GetRetryDelay(int attempt) + { + double multiplier = Math.Pow(2, Math.Max(0, attempt - 1)); + double delayMilliseconds = _initialRetryDelay.TotalMilliseconds * multiplier; + return TimeSpan.FromMilliseconds(Math.Min(delayMilliseconds, TimeSpan.FromSeconds(30).TotalMilliseconds)); + } + + 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/Contracts/IDownloadFileMetadataReader.cs b/GenLauncherGO.Infrastructure/Updating/Contracts/IDownloadFileMetadataReader.cs new file mode 100644 index 00000000..b50d66ea --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Contracts/IDownloadFileMetadataReader.cs @@ -0,0 +1,13 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Contracts; + +internal interface IDownloadFileMetadataReader +{ + Task ReadMetadataAsync( + Uri downloadUri, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Contracts/IFileHashService.cs b/GenLauncherGO.Infrastructure/Updating/Contracts/IFileHashService.cs new file mode 100644 index 00000000..4a35112b --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Contracts/IFileHashService.cs @@ -0,0 +1,12 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Updating.Contracts; + +internal interface IFileHashService +{ + /// + /// Computes an uppercase hexadecimal MD5 hash for a local file. + /// + Task ComputeMd5HashAsync(string filePath, CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Contracts/IResumableFileDownloader.cs b/GenLauncherGO.Infrastructure/Updating/Contracts/IResumableFileDownloader.cs new file mode 100644 index 00000000..bf67c77e --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Contracts/IResumableFileDownloader.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Contracts; + +internal interface IResumableFileDownloader +{ + Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Contracts/IS3ObjectManifestReader.cs b/GenLauncherGO.Infrastructure/Updating/Contracts/IS3ObjectManifestReader.cs new file mode 100644 index 00000000..6b6fd1fa --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Contracts/IS3ObjectManifestReader.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Contracts; + +internal interface IS3ObjectManifestReader +{ + 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..8e671e41 --- /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; + +internal interface IS3PackageUpdater +{ + Task UpdateAsync( + S3PackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null); + + /// + /// Downloads and repairs selected package files directly inside an installed S3-backed package. + /// + Task RepairFilesAsync( + S3PackageFileRepairRequest request, + IProgress? progress, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Contracts/ISingleFilePackageUpdater.cs b/GenLauncherGO.Infrastructure/Updating/Contracts/ISingleFilePackageUpdater.cs new file mode 100644 index 00000000..99cc3158 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Contracts/ISingleFilePackageUpdater.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Contracts; + +internal interface ISingleFilePackageUpdater +{ + Task UpdateAsync( + DownloadFileMetadata metadata, + PackageUpdatePathSet paths, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Models/DownloadFileMetadata.cs b/GenLauncherGO.Infrastructure/Updating/Models/DownloadFileMetadata.cs new file mode 100644 index 00000000..9386e5f1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/DownloadFileMetadata.cs @@ -0,0 +1,8 @@ +using System; + +namespace GenLauncherGO.Infrastructure.Updating.Models; + +internal sealed record DownloadFileMetadata( + Uri DownloadUri, + string FileName, + long? TotalBytes); diff --git a/GenLauncherGO.Infrastructure/Updating/Models/DownloadFileRequest.cs b/GenLauncherGO.Infrastructure/Updating/Models/DownloadFileRequest.cs new file mode 100644 index 00000000..60f887c5 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/DownloadFileRequest.cs @@ -0,0 +1,11 @@ +using System; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Models; + +internal sealed record DownloadFileRequest( + Uri SourceUri, + string DestinationFilePath, + long? ExpectedBytes = null, + bool Resume = true, + PackageDownloadPauseController? PauseController = null); diff --git a/GenLauncherGO.Infrastructure/Updating/Models/DownloadProgress.cs b/GenLauncherGO.Infrastructure/Updating/Models/DownloadProgress.cs new file mode 100644 index 00000000..74876987 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/DownloadProgress.cs @@ -0,0 +1,6 @@ +namespace GenLauncherGO.Infrastructure.Updating.Models; + +internal sealed record DownloadProgress( + long? TotalBytes, + long BytesDownloaded, + double? ProgressPercentage); diff --git a/GenLauncherGO.Infrastructure/Updating/Models/PackageUpdatePathSet.cs b/GenLauncherGO.Infrastructure/Updating/Models/PackageUpdatePathSet.cs new file mode 100644 index 00000000..6f036bd1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/PackageUpdatePathSet.cs @@ -0,0 +1,49 @@ +using System; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Infrastructure.Updating.Models; + +/// +/// Describes the explicit ownership boundaries for package staging, installed content, and durable recovery. +/// +internal sealed record PackageUpdatePathSet +{ + public PackageUpdatePathSet( + OwnedContentPath temporaryPath, + OwnedContentPath installedPath, + OwnedContentPath backupPath, + OwnedContentPath? latestInstalledPath = null) + { + TemporaryPath = temporaryPath ?? throw new ArgumentNullException(nameof(temporaryPath)); + InstalledPath = installedPath ?? throw new ArgumentNullException(nameof(installedPath)); + BackupPath = backupPath ?? throw new ArgumentNullException(nameof(backupPath)); + LatestInstalledPath = latestInstalledPath; + } + + public OwnedContentPath TemporaryPath { get; } + + public OwnedContentPath InstalledPath { get; } + + public OwnedContentPath BackupPath { get; } + + public OwnedContentPath? LatestInstalledPath { get; } + + /// + /// Creates package paths from canonical launcher paths and a centrally resolved installed path. + /// + public static PackageUpdatePathSet Create( + LauncherPaths launcherPaths, + OwnedContentPath installedPath, + OwnedContentPath? latestInstalledPath = null) + { + ArgumentNullException.ThrowIfNull(launcherPaths); + ArgumentNullException.ThrowIfNull(installedPath); + + return new PackageUpdatePathSet( + launcherPaths.GetPackageTemporaryPath(installedPath), + installedPath, + launcherPaths.GetPackageBackupPath(installedPath), + latestInstalledPath); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Models/RemoteFileManifestEntry.cs b/GenLauncherGO.Infrastructure/Updating/Models/RemoteFileManifestEntry.cs new file mode 100644 index 00000000..b24ab211 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/RemoteFileManifestEntry.cs @@ -0,0 +1,6 @@ +namespace GenLauncherGO.Infrastructure.Updating.Models; + +internal sealed record RemoteFileManifestEntry( + string FileName, + string Hash, + ulong Size); diff --git a/GenLauncherGO.Infrastructure/Updating/Models/S3ObjectManifestRequest.cs b/GenLauncherGO.Infrastructure/Updating/Models/S3ObjectManifestRequest.cs new file mode 100644 index 00000000..a1ca59ed --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/S3ObjectManifestRequest.cs @@ -0,0 +1,16 @@ +namespace GenLauncherGO.Infrastructure.Updating.Models; + +/// +/// Describes an S3-compatible object listing request for a modification version. +/// +/// +/// UseSsl defaults to for compatibility with legacy catalog endpoints that expose +/// plain MinIO ports; an explicit endpoint URI scheme takes precedence. +/// +internal sealed record S3ObjectManifestRequest( + string Endpoint, + string BucketName, + string Prefix, + string AccessKey, + string SecretKey, + bool UseSsl = false); diff --git a/GenLauncherGO.Infrastructure/Updating/Models/S3PackageFileRepairRequest.cs b/GenLauncherGO.Infrastructure/Updating/Models/S3PackageFileRepairRequest.cs new file mode 100644 index 00000000..8678286a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/S3PackageFileRepairRequest.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Models; + +/// +/// Describes selected S3-backed package files that should be repaired in place. +/// +internal sealed record S3PackageFileRepairRequest( + IReadOnlyList Files, + S3ObjectManifestRequest Source, + OwnedContentPath InstalledPath, + IReadOnlySet HashCheckedExtensions); diff --git a/GenLauncherGO.Infrastructure/Updating/Models/S3PackageUpdateRequest.cs b/GenLauncherGO.Infrastructure/Updating/Models/S3PackageUpdateRequest.cs new file mode 100644 index 00000000..83353028 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/S3PackageUpdateRequest.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Updating.Models; + +/// +/// Describes an S3-backed package update. +/// +internal sealed record S3PackageUpdateRequest( + IReadOnlyList Files, + S3ObjectManifestRequest Source, + PackageUpdatePathSet PathSet, + IReadOnlySet HashCheckedExtensions); diff --git a/GenLauncherGO.Infrastructure/Updating/Services/ManagedPackageSourceResolver.cs b/GenLauncherGO.Infrastructure/Updating/Services/ManagedPackageSourceResolver.cs new file mode 100644 index 00000000..09fc9abe --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/ManagedPackageSourceResolver.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Resolves and caches the remote metadata shared by package sizing, installation, and repair. +/// +internal sealed class ManagedPackageSourceResolver : IRemotePackageSizeResolver +{ + private readonly Dictionary _sourceCache = []; + private readonly Lock _cacheSync = new(); + private readonly IDownloadFileMetadataReader _downloadFileMetadataReader; + private readonly ILogger _logger; + private readonly IS3ObjectManifestReader _s3ObjectManifestReader; + private readonly HashSet _unavailableSizes = []; + + public ManagedPackageSourceResolver( + IDownloadFileMetadataReader downloadFileMetadataReader, + IS3ObjectManifestReader s3ObjectManifestReader, + ILogger logger) + { + _downloadFileMetadataReader = downloadFileMetadataReader ?? + throw new ArgumentNullException(nameof(downloadFileMetadataReader)); + _s3ObjectManifestReader = s3ObjectManifestReader ?? + throw new ArgumentNullException(nameof(s3ObjectManifestReader)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetTotalBytesAsync( + LauncherContentVersion version, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(version); + + var cacheKey = CacheKey.Create(version); + lock (_cacheSync) + { + if (_sourceCache.TryGetValue(cacheKey, out PackageSource? cachedSource)) + { + return cachedSource.TotalBytes; + } + + if (_unavailableSizes.Contains(cacheKey)) + { + return null; + } + } + + long? totalBytes; + try + { + PackageSource? source = await ResolveAsync(version, cancellationToken).ConfigureAwait(false); + totalBytes = source?.TotalBytes; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning( + "Failed to resolve package size for {ContentIdentity} from {SourceKind}; failure type: {FailureType}.", + version.ContentKey.ToStableString(), + version.EffectiveContentSourceKind, + exception.GetType().Name); + totalBytes = null; + } + + lock (_cacheSync) + { + if (!_sourceCache.ContainsKey(cacheKey)) + { + _unavailableSizes.Add(cacheKey); + } + } + + return totalBytes; + } + + /// + /// Resolves one managed package source while retaining successful provider metadata for later consumers. + /// + public async Task ResolveAsync( + LauncherContentVersion version, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(version); + cancellationToken.ThrowIfCancellationRequested(); + + var cacheKey = CacheKey.Create(version); + lock (_cacheSync) + { + if (_sourceCache.TryGetValue(cacheKey, out PackageSource? cachedSource)) + { + return cachedSource; + } + } + + PackageSource? source = await ResolveCoreAsync(version, cancellationToken).ConfigureAwait(false); + if (source is not null) + { + lock (_cacheSync) + { + _sourceCache[cacheKey] = source; + _unavailableSizes.Remove(cacheKey); + } + } + + return source; + } + + private async Task ResolveCoreAsync( + LauncherContentVersion version, + CancellationToken cancellationToken) + { + switch (version.EffectiveContentSourceKind) + { + case ContentSourceKind.ManagedS3: + S3ObjectManifestRequest request = S3CatalogDefaults.CreateManifestRequest(version); + IReadOnlyList files = + await _s3ObjectManifestReader.ReadManifestAsync(request, cancellationToken).ConfigureAwait(false); + return new PackageSource.S3(request, files); + + case ContentSourceKind.ManagedSingleFile: + Uri downloadUri = DownloadLinkResolver.ResolveDownloadUri(version.SimpleDownloadLink); + DownloadFileMetadata metadata = await _downloadFileMetadataReader.ReadMetadataAsync( + downloadUri, + cancellationToken).ConfigureAwait(false); + return new PackageSource.SingleFile(metadata); + + default: + _logger.LogDebug( + "Package metadata is unavailable for {ContentIdentity} with unsupported source {SourceKind}.", + version.ContentKey.ToStableString(), + version.EffectiveContentSourceKind); + return null; + } + } + + private static long? SumManifestBytes(IEnumerable files) + { + try + { + ulong totalSize = 0; + foreach (RemoteFileManifestEntry entry in files) + { + totalSize = checked(totalSize + entry.Size); + } + + return checked((long)totalSize); + } + catch (OverflowException) + { + return null; + } + } + + internal abstract record PackageSource(long? TotalBytes) + { + internal sealed record S3( + S3ObjectManifestRequest Request, + IReadOnlyList Files) : PackageSource(SumManifestBytes(Files)); + + internal sealed record SingleFile(DownloadFileMetadata Metadata) : + PackageSource(Metadata.TotalBytes is >= 0 ? Metadata.TotalBytes : null); + } + + private readonly record struct CacheKey( + LauncherContentKey ContentKey, + ContentSourceKind SourceKind, + string S3Host, + string S3Bucket, + string S3Folder, + string S3PublicKey, + string S3SecretKey, + string DirectDownloadLink) + { + public static CacheKey Create(LauncherContentVersion version) + { + return new CacheKey( + version.ContentKey, + version.EffectiveContentSourceKind, + version.S3HostLink, + version.S3BucketName, + version.S3FolderName, + version.S3HostPublicKey, + version.S3HostSecretKey, + version.SimpleDownloadLink); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/Md5FileHashService.cs b/GenLauncherGO.Infrastructure/Updating/Services/Md5FileHashService.cs new file mode 100644 index 00000000..21c76e1a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/Md5FileHashService.cs @@ -0,0 +1,52 @@ +using System; +using System.Globalization; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Computes MD5 hashes for local files. +/// +internal sealed class Md5FileHashService : IFileHashService +{ + private readonly ILogger _logger; + + public Md5FileHashService(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + public async Task ComputeMd5HashAsync(string filePath, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + try + { + 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); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException + or CryptographicException) + { + _logger.LogWarning( + exception, + "Failed to compute MD5 hash for {FileName}.", + Path.GetFileName(filePath)); + throw; + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/PackageDownloadService.cs b/GenLauncherGO.Infrastructure/Updating/Services/PackageDownloadService.cs new file mode 100644 index 00000000..0794a809 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/PackageDownloadService.cs @@ -0,0 +1,259 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Mods.Services; +using GenLauncherGO.Core.Startup; +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; +using Minio.Exceptions; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Downloads and installs modification packages while keeping provider selection inside Infrastructure. +/// +internal sealed class PackageDownloadService : IPackageDownloadService +{ + private readonly ILogger _logger; + private readonly ManagedPackageSourceResolver _packageSourceResolver; + private readonly LauncherRuntimePathContext _runtimePathContext; + private readonly IS3PackageUpdater _s3PackageUpdater; + + private readonly ISingleFilePackageUpdater _singleFilePackageUpdater; + + public PackageDownloadService( + ISingleFilePackageUpdater singleFilePackageUpdater, + IS3PackageUpdater s3PackageUpdater, + ManagedPackageSourceResolver packageSourceResolver, + LauncherRuntimePathContext runtimePathContext, + ILogger logger) + { + _singleFilePackageUpdater = singleFilePackageUpdater ?? + throw new ArgumentNullException(nameof(singleFilePackageUpdater)); + _s3PackageUpdater = s3PackageUpdater ?? throw new ArgumentNullException(nameof(s3PackageUpdater)); + _packageSourceResolver = packageSourceResolver ?? + throw new ArgumentNullException(nameof(packageSourceResolver)); + _runtimePathContext = runtimePathContext ?? throw new ArgumentNullException(nameof(runtimePathContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task DownloadAsync( + LauncherContent modification, + LauncherContentVersion version, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + ArgumentNullException.ThrowIfNull(modification); + ArgumentNullException.ThrowIfNull(version); + pauseController ??= new PackageDownloadPauseController(); + + IProgress? monotonicProgress = progress is null + ? null + : new MonotonicPackageProgress(progress); + + try + { + await pauseController.WaitWhilePausedAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + LauncherPaths paths = _runtimePathContext.ActivePaths; + ManagedPackageSourceResolver.PackageSource? source = await _packageSourceResolver.ResolveAsync( + version, + cancellationToken).ConfigureAwait(false); + + switch (source) + { + case ManagedPackageSourceResolver.PackageSource.S3 s3Source: + await DownloadS3PackageAsync( + modification, + version, + s3Source, + paths, + monotonicProgress, + pauseController, + cancellationToken).ConfigureAwait(false); + break; + + case ManagedPackageSourceResolver.PackageSource.SingleFile singleFileSource: + await DownloadSingleFilePackageAsync( + version, + singleFileSource.Metadata, + paths, + monotonicProgress, + pauseController, + cancellationToken).ConfigureAwait(false); + break; + + default: + throw new InvalidOperationException( + $"Content source '{version.EffectiveContentSourceKind}' is not managed by the package downloader."); + } + + _logger.LogInformation( + "Completed package download for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + return PackageDownloadResult.Succeeded(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogInformation( + "Canceled package download for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + return PackageDownloadResult.Canceled(); + } + catch (Exception exception) when (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug( + exception, + "Package provider surfaced a failure after cancellation for {ContentName} {ContentVersion}; treating the pre-commit operation as canceled.", + version.Name, + version.Version); + return PackageDownloadResult.Canceled(); + } + catch (UnexpectedMinioException exception) + { + return CreateRecoverableProviderFailure(version, exception); + } + catch (Exception exception) when (exception is HttpRequestException or TimeoutException) + { + return CreateRecoverableProviderFailure(version, exception); + } + catch (InvalidDataException exception) + { + _logger.LogWarning( + exception, + "Package validation failed for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + return PackageDownloadResult.RecoverableFailure( + "The downloaded package could not be validated."); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + _logger.LogWarning( + exception, + "Package staging or installation failed for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + return PackageDownloadResult.RecoverableFailure( + "The package could not be staged or installed in launcher storage."); + } + catch (Exception exception) + { + _logger.LogError( + exception, + "Package download failed unexpectedly for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + return PackageDownloadResult.UnexpectedFailure( + "An unexpected package download error occurred."); + } + } + + private async Task DownloadSingleFilePackageAsync( + LauncherContentVersion version, + DownloadFileMetadata metadata, + LauncherPaths paths, + IProgress? progress, + PackageDownloadPauseController pauseController, + CancellationToken cancellationToken) + { + PackageUpdatePathSet packagePaths = CreatePackagePaths(paths, version); + OwnedDirectoryTree.EnsureExists( + packagePaths.TemporaryPath.OwnerRoot, + packagePaths.TemporaryPath.FullPath); + + _logger.LogInformation( + "Starting single-file package download for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + + await _singleFilePackageUpdater.UpdateAsync( + metadata, + packagePaths, + progress, + cancellationToken, + pauseController).ConfigureAwait(false); + } + + private async Task DownloadS3PackageAsync( + LauncherContent modification, + LauncherContentVersion version, + ManagedPackageSourceResolver.PackageSource.S3 source, + LauncherPaths paths, + IProgress? progress, + PackageDownloadPauseController pauseController, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Starting S3 package download for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + + await pauseController.WaitWhilePausedAsync(cancellationToken).ConfigureAwait(false); + LauncherContentVersion? latestInstalledVersion = modification.LatestInstalledVersion; + PackageUpdatePathSet packagePaths = CreatePackagePaths( + paths, + version, + latestInstalledVersion); + + await _s3PackageUpdater.UpdateAsync( + new S3PackageUpdateRequest( + source.Files, + source.Request, + packagePaths, + S3HashValidationPolicy.InstallHashCheckedExtensions), + progress, + cancellationToken, + pauseController).ConfigureAwait(false); + } + + private static PackageUpdatePathSet CreatePackagePaths( + LauncherPaths paths, + LauncherContentVersion version, + LauncherContentVersion? latestInstalledVersion = null) + { + OwnedContentPath installedPath = ResolveVersionPath(paths, version); + OwnedContentPath? latestInstalledPath = latestInstalledVersion is null + ? null + : ResolveVersionPath(paths, latestInstalledVersion); + return PackageUpdatePathSet.Create( + paths, + installedPath, + latestInstalledPath); + } + + private static OwnedContentPath ResolveVersionPath( + LauncherPaths paths, + LauncherContentVersion version) + { + return LauncherContentPathResolver.ResolveVersionPath( + paths, + version.ContentKey) + ?? throw new InvalidOperationException( + "The package version did not resolve to a supported launcher content path."); + } + + private PackageDownloadResult CreateRecoverableProviderFailure( + LauncherContentVersion version, + Exception exception) + { + _logger.LogWarning( + exception, + "Remote package provider failed for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + return PackageDownloadResult.RecoverableFailure( + "The remote package provider could not complete the download."); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/S3PackageUpdater.cs b/GenLauncherGO.Infrastructure/Updating/Services/S3PackageUpdater.cs new file mode 100644 index 00000000..76f1209f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/S3PackageUpdater.cs @@ -0,0 +1,582 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.IO; +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; +using Minio; +using Minio.DataModel.Args; +using MinioClientFactory = GenLauncherGO.Infrastructure.Updating.Clients.MinioClientFactory; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +internal sealed class S3PackageUpdater : IS3PackageUpdater +{ + private const int MaxHashAttempts = 3; + private const int MaxConcurrentFileDownloads = 6; + private const int PresignedUrlLifetimeHours = 12; + + private readonly IResumableFileDownloader _fileDownloader; + private readonly IFileHashService _fileHashService; + private readonly ILogger _logger; + private readonly S3ReusablePackageFileCopier _reusableFileCopier; + + public S3PackageUpdater( + IResumableFileDownloader fileDownloader, + IFileHashService fileHashService, + ILogger logger) + { + _fileDownloader = fileDownloader ?? throw new ArgumentNullException(nameof(fileDownloader)); + _fileHashService = fileHashService ?? throw new ArgumentNullException(nameof(fileHashService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _reusableFileCopier = new S3ReusablePackageFileCopier(_fileHashService, _logger); + } + + /// + /// 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. + /// + public async Task UpdateAsync( + S3PackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.Source); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Source.AccessKey); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Source.SecretKey); + ArgumentNullException.ThrowIfNull(request.PathSet); + + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + PackageUpdatePathSet ownedPaths = request.PathSet; + string temporaryFolderPath = ownedPaths.TemporaryPath.FullPath; + EnsureUniqueManifestDestinations(temporaryFolderPath, request.Files); + PackageStagingFolderCleaner.RemoveUnsafeLinks( + ownedPaths.TemporaryPath, + _logger, + cancellationToken); + _logger.LogDebug( + "Starting S3 package update for bucket {BucketName}, folder {FolderName}; files: {FileCount}.", + request.Source.BucketName, + request.Source.Prefix, + request.Files.Count); + + if (ownedPaths.LatestInstalledPath is not null && + Directory.Exists(ownedPaths.LatestInstalledPath.FullPath)) + { + await _reusableFileCopier.CopyUnchangedFilesAsync( + ownedPaths.LatestInstalledPath, + ownedPaths.TemporaryPath, + request.Files, + cancellationToken).ConfigureAwait(false); + } + + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + await DownloadFilesAsync( + request.Files, + request.Source, + temporaryFolderPath, + request.HashCheckedExtensions, + progress, + pauseController, + cancellationToken).ConfigureAwait(false); + + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + PackageStagingFolderCleaner.PruneToManifest( + ownedPaths.TemporaryPath, + request.Files, + _logger, + cancellationToken); + + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + PackageInstallFolderReplacer.Replace( + ownedPaths.TemporaryPath, + ownedPaths.InstalledPath, + ownedPaths.BackupPath, + _logger); + PackageStagingFolderCleaner.DeleteEmptyPackageParents(ownedPaths.TemporaryPath, _logger); + _logger.LogDebug( + "Completed S3 package update for bucket {BucketName}, folder {FolderName}.", + request.Source.BucketName, + request.Source.Prefix); + } + + /// + /// Downloads selected S3 manifest files directly into an installed package folder, validating reliable hashes and + /// preserving unrelated installed files. + /// + public async Task RepairFilesAsync( + S3PackageFileRepairRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.Source); + ArgumentNullException.ThrowIfNull(request.InstalledPath); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Source.AccessKey); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Source.SecretKey); + string installedFolderPath = request.InstalledPath.FullPath; + + EnsureUniqueManifestDestinations(installedFolderPath, request.Files); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + Path.GetDirectoryName(installedFolderPath) ?? request.InstalledPath.OwnerRoot, + "Installed package paths"); + Directory.CreateDirectory(installedFolderPath); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + installedFolderPath, + "Installed package paths"); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + installedFolderPath, + "Installed package paths"); + _logger.LogDebug( + "Starting S3 package file repair for bucket {BucketName}, folder {FolderName}; files: {FileCount}.", + request.Source.BucketName, + request.Source.Prefix, + request.Files.Count); + + await DownloadFilesAsync( + request.Files, + request.Source, + installedFolderPath, + request.HashCheckedExtensions, + progress, + null, + cancellationToken).ConfigureAwait(false); + _logger.LogDebug( + "Completed S3 package file repair for bucket {BucketName}, folder {FolderName}.", + request.Source.BucketName, + request.Source.Prefix); + } + + /// + /// Runs the shared resumable, validated, concurrent S3 transfer lifecycle for an update or in-place repair. + /// + private async Task DownloadFilesAsync( + IReadOnlyList files, + S3ObjectManifestRequest source, + string destinationFolderPath, + IReadOnlySet hashCheckedExtensions, + IProgress? progress, + PackageDownloadPauseController? pauseController, + CancellationToken cancellationToken) + { + List downloadWorkItems = await CreateDownloadWorkItemsAsync( + files, + destinationFolderPath, + hashCheckedExtensions, + cancellationToken).ConfigureAwait(false); + // Progress is measured against the whole package, not just what is left to fetch. Otherwise a resumed + // download reports a shrinking total and a bar that restarts at zero, both of which read as lost work. + long totalPackageSize = files.Sum(file => (long)file.Size); + long remainingDownloadSize = downloadWorkItems.Sum(download => download.BytesToDownload); + long resumedBytes = Math.Max(0, totalPackageSize - remainingDownloadSize); + var progressState = new PackageProgressTracker(totalPackageSize, resumedBytes); + if (downloadWorkItems.Count == 0) + { + progress?.Report(new PackageUpdateProgress(0, 0, 100, null)); + } + + // Disposed in the finally below rather than by `using` declarations: both are + // captured by the download tasks, so they must outlive every task instead of + // being released when this scope exits. + SemaphoreSlim downloadSlots = new(MaxConcurrentFileDownloads); + IMinioClient client = MinioClientFactory.Create( + source.Endpoint, + source.AccessKey, + source.SecretKey, + source.UseSsl); + + var downloadTasks = downloadWorkItems + .Select(download => DownloadFileWithSlotAsync( + client, + source.BucketName, + source.Prefix, + destinationFolderPath, + hashCheckedExtensions, + download, + progressState, + progress, + downloadSlots, + pauseController, + cancellationToken)) + .ToList(); + + try + { + await Task.WhenAll(downloadTasks).ConfigureAwait(false); + } + finally + { + // Task.WhenAll surfaces the first failure or cancellation immediately, while the + // remaining downloads are still running and still holding the semaphore and the + // client. Every task has to settle before either is released, rather than only + // the one that failed. + foreach (Task downloadTask in downloadTasks) + { + await downloadTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + + client.Dispose(); + downloadSlots.Dispose(); + } + } + + /// + /// Creates the set of manifest files that still require remote transfer after reusable files are staged. + /// + private async Task> CreateDownloadWorkItemsAsync( + IReadOnlyList files, + string destinationFolderPath, + IReadOnlySet hashCheckedExtensions, + CancellationToken cancellationToken) + { + List downloads = []; + foreach (RemoteFileManifestEntry file in files) + { + string destinationFilePath = ManifestPathResolver.ResolvePath( + destinationFolderPath, + file.FileName); + EnsureSafeDestinationPath(destinationFolderPath, destinationFilePath); + if (await CheckFileSuccessDownloadAsync( + file, + destinationFilePath, + hashCheckedExtensions, + cancellationToken).ConfigureAwait(false)) + { + continue; + } + + 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; + } + + private static bool ExistingDownloadedFileMatchesExpectedSize( + RemoteFileManifestEntry file, + string destinationFilePath) + { + string existingFilePath = BigFileVariantPath.GetExistingDownloadedPath(destinationFilePath); + return !string.IsNullOrWhiteSpace(existingFilePath) && + new FileInfo(existingFilePath).Length == (long)file.Size; + } + + private async Task DownloadFileWithSlotAsync( + IMinioClient client, + string bucketName, + string folderName, + string destinationFolderPath, + IReadOnlySet hashCheckedExtensions, + S3DownloadWorkItem download, + PackageProgressTracker progressState, + IProgress? progress, + SemaphoreSlim downloadSlots, + PackageDownloadPauseController? pauseController, + CancellationToken cancellationToken) + { + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + await downloadSlots.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await DownloadVerifiedFileAsync( + client, + bucketName, + folderName, + destinationFolderPath, + hashCheckedExtensions, + download, + progressState, + progress, + pauseController, + cancellationToken).ConfigureAwait(false); + } + finally + { + downloadSlots.Release(); + } + } + + /// + /// Downloads a file, validates size and hash when available, and retries hash mismatches. + /// + private async Task DownloadVerifiedFileAsync( + IMinioClient client, + string bucketName, + string folderName, + string destinationFolderPath, + IReadOnlySet hashCheckedExtensions, + S3DownloadWorkItem download, + PackageProgressTracker progressState, + IProgress? progress, + PackageDownloadPauseController? pauseController, + CancellationToken cancellationToken) + { + RemoteFileManifestEntry file = download.File; + string destinationFilePath = ManifestPathResolver.ResolvePath( + destinationFolderPath, + file.FileName); + EnsureSafeDestinationPath(destinationFolderPath, destinationFilePath); + + 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); + string progressItemName = $"{file.FileName}#attempt-{attempt}"; + if (attempt > 1) + { + progressState.AddExpectedBytes((long)file.Size); + } + + Uri downloadUri = await BuildDownloadUriAsync( + client, + bucketName, + folderName, + file.FileName).ConfigureAwait(false); + IProgress downloadProgress = new InlineProgress(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; + long verifiedBytes = completed && expectedTransferBytes > 0 + ? Math.Min(transferredBytes, expectedTransferBytes - 1) + : transferredBytes; + ReportFileProgress( + progressState, + progress, + progressItemName, + verifiedBytes, + completed); + }); + + await _fileDownloader.DownloadFileAsync( + new DownloadFileRequest( + downloadUri, + destinationFilePath, + (long)file.Size, + true, + pauseController), + downloadProgress, + cancellationToken).ConfigureAwait(false); + + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + BigFileVariantPath.ConvertBigFileToGib(destinationFilePath); + + if (await CheckFileSuccessDownloadAsync( + file, + destinationFilePath, + hashCheckedExtensions, + cancellationToken).ConfigureAwait(false)) + { + ReportFileProgress( + progressState, + progress, + progressItemName, + expectedTransferBytes, + true); + return; + } + + if (attempt == MaxHashAttempts) + { + throw new IOException("Downloaded file validation failed after repeated download attempts."); + } + + // Account for the completed but invalid transfer without publishing a false terminal 100% report. + progressState.CompleteItemSilently(progressItemName, expectedTransferBytes); + _logger.LogWarning( + "Downloaded file validation failed for {FileName}; retrying download attempt {NextAttempt}.", + file.FileName, + attempt + 1); + DeleteFailedFile(destinationFilePath); + await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false); + } + } + + 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); + } + + private static async Task BuildDownloadUriAsync( + IMinioClient client, + string bucketName, + string folderName, + string fileName) + { + int expirySeconds = (int)Math.Min( + TimeSpan.FromHours(PresignedUrlLifetimeHours).TotalSeconds, + int.MaxValue); + string objectName = BuildObjectName(folderName, fileName); + PresignedGetObjectArgs args = new PresignedGetObjectArgs() + .WithBucket(bucketName) + .WithObject(objectName) + .WithExpiry(expirySeconds); + string presignedUrl = await client.PresignedGetObjectAsync(args).ConfigureAwait(false); + return new Uri(presignedUrl, UriKind.Absolute); + } + + 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. + /// + private void DeleteFailedFile(string destinationFilePath) + { + if (File.Exists(destinationFilePath)) + { + File.Delete(destinationFilePath); + _logger.LogDebug( + "Deleted failed downloaded file {FileName}.", + Path.GetFileName(destinationFilePath)); + } + + string gibFilePath = BigFileVariantPath.GetGibVariantPath(destinationFilePath); + if (File.Exists(gibFilePath)) + { + File.Delete(gibFilePath); + _logger.LogDebug( + "Deleted failed converted file {FileName}.", + Path.GetFileName(gibFilePath)); + } + } + + private static string BuildObjectName(string folderName, string fileName) + { + string normalizedFolderName = LexicalPath.NormalizeRelativePath(folderName); + string normalizedFileName = ManifestPathResolver.NormalizeForManifestIndex(fileName); + if (string.IsNullOrWhiteSpace(normalizedFolderName)) + { + return normalizedFileName; + } + + return $"{normalizedFolderName}/{normalizedFileName}"; + } + + 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); + } + } + + /// + /// Creates a manifest file's parent inside the package root and rejects linked path segments before file access. + /// + private static void EnsureSafeDestinationPath( + string packageRoot, + string destinationFilePath) + { + string destinationDirectory = Path.GetDirectoryName(destinationFilePath) ?? packageRoot; + if (!LexicalPath.AreEquivalent(packageRoot, destinationDirectory)) + { + OwnedDirectoryTree.EnsureExists(packageRoot, destinationDirectory); + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationFilePath, + "Package file paths"); + } + + /// + /// Rejects manifest aliases that would write or convert more than one object into the same local file. + /// + private static void EnsureUniqueManifestDestinations( + string packageRoot, + IReadOnlyList files) + { + HashSet destinations = new(StringComparer.OrdinalIgnoreCase); + foreach (RemoteFileManifestEntry file in files) + { + string destinationPath = ManifestPathResolver.ResolveInstalledPath(packageRoot, file.FileName); + + if (!destinations.Add(destinationPath)) + { + throw new InvalidDataException( + "The remote package manifest contains duplicate local file destinations."); + } + } + } + + 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..8076158f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/SingleFilePackageUpdater.cs @@ -0,0 +1,137 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Archives.Contracts; +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.Updating.Services; + +internal sealed class SingleFilePackageUpdater : ISingleFilePackageUpdater +{ + private readonly IArchiveExtractor _archiveExtractor; + private readonly IResumableFileDownloader _fileDownloader; + private readonly ILogger _logger; + public SingleFilePackageUpdater( + IResumableFileDownloader fileDownloader, + IArchiveExtractor archiveExtractor, + ILogger logger) + { + _fileDownloader = fileDownloader ?? throw new ArgumentNullException(nameof(fileDownloader)); + _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. + /// + public async Task UpdateAsync( + DownloadFileMetadata metadata, + PackageUpdatePathSet paths, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + ArgumentNullException.ThrowIfNull(metadata); + ArgumentNullException.ThrowIfNull(paths); + + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + string temporaryFolderPath = paths.TemporaryPath.FullPath; + PackageStagingFolderCleaner.ClearDirectory(paths.TemporaryPath, _logger); + _logger.LogDebug( + "Starting single-file package update from {Host}.", + metadata.DownloadUri.Host); + + string destinationFilePath = ResolveMetadataFilePath( + temporaryFolderPath, + metadata.FileName); + bool extractionRequired = LauncherContentFileTypes.IsArchive(destinationFilePath); + var progressTracker = new PackageProgressTracker(metadata.TotalBytes); + + IProgress downloadProgress = new InlineProgress(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, + true, + pauseController), + downloadProgress, + cancellationToken).ConfigureAwait(false); + + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + if (extractionRequired) + { + await Task.Run( + () => _archiveExtractor.ExtractToDirectory( + destinationFilePath, + temporaryFolderPath, + true, + cancellationToken), + cancellationToken).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + if (File.Exists(destinationFilePath)) + { + File.Delete(destinationFilePath); + _logger.LogDebug( + "Deleted downloaded archive {FileName} after extraction.", + Path.GetFileName(destinationFilePath)); + } + } + + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + _logger); + PackageStagingFolderCleaner.DeleteEmptyPackageParents(paths.TemporaryPath, _logger); + _logger.LogDebug("Completed single-file package update."); + } + + /// + /// Resolves an HTTP metadata file name as one direct child of the owned staging folder. + /// + private static string ResolveMetadataFilePath( + string temporaryFolderPath, + string fileName) + { + string safeFileName; + try + { + safeFileName = LexicalPath.NormalizePathSegment(fileName, nameof(fileName)); + } + catch (ArgumentException exception) + { + throw new InvalidDataException( + "Remote package metadata did not provide a safe direct file name.", + exception); + } + + return ManifestPathResolver.ResolvePath(temporaryFolderPath, safeFileName); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/DownloadLinkResolver.cs b/GenLauncherGO.Infrastructure/Updating/Support/DownloadLinkResolver.cs new file mode 100644 index 00000000..2a14cce8 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/DownloadLinkResolver.cs @@ -0,0 +1,76 @@ +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. +/// +internal static class DownloadLinkResolver +{ + public static Uri ResolveDownloadUri(string link) + { + return new Uri(ResolveDirectDownloadLink(link), UriKind.Absolute); + } + + /// + /// Converts supported share links into direct download links. + /// + /// + /// 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", StringComparison.Ordinal); + } + + 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. + /// + private static string ResolveOneDriveLink(string link) + { + if (link.Contains("embed", StringComparison.Ordinal)) + { + return link.Replace("embed", "download", StringComparison.Ordinal); + } + + List linkParts = [.. link.Replace("https://onedrive.live.com/?", string.Empty, StringComparison.Ordinal).Split('&')]; + string? cid = linkParts.Where(t => t.Contains("cid=", StringComparison.Ordinal)) + .Select(t => t.Replace("cid=", string.Empty, StringComparison.Ordinal)) + .FirstOrDefault(); + string? authKey = linkParts.Where(t => t.Contains("authkey=", StringComparison.Ordinal)) + .Select(t => t.Replace("authkey=", string.Empty, StringComparison.Ordinal)) + .FirstOrDefault(); + string? resid = linkParts.Where(t => + t.Contains("id=", StringComparison.Ordinal) && + !t.Contains("cid=", StringComparison.Ordinal)) + .Select(t => t.Replace("id=", string.Empty, StringComparison.Ordinal)) + .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/InlineProgress.cs b/GenLauncherGO.Infrastructure/Updating/Support/InlineProgress.cs new file mode 100644 index 00000000..63b55e1a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/InlineProgress.cs @@ -0,0 +1,21 @@ +using System; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Invokes an internal progress callback inline so provider aggregation completes before the owning operation. +/// +internal sealed class InlineProgress : IProgress +{ + private readonly Action _report; + + public InlineProgress(Action report) + { + _report = report ?? throw new ArgumentNullException(nameof(report)); + } + + public void Report(T value) + { + _report(value); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/MonotonicPackageProgress.cs b/GenLauncherGO.Infrastructure/Updating/Support/MonotonicPackageProgress.cs new file mode 100644 index 00000000..1d8f711d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/MonotonicPackageProgress.cs @@ -0,0 +1,66 @@ +using System; +using System.Threading; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Normalizes concurrent provider reports so package progress never moves backwards. +/// +internal sealed class MonotonicPackageProgress : IProgress +{ + private readonly IProgress _inner; + private readonly Lock _syncRoot = new(); + + private long _bytesRead; + private double? _percentage; + private long? _totalBytes; + + public MonotonicPackageProgress(IProgress inner) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + } + + public void Report(PackageUpdateProgress value) + { + ArgumentNullException.ThrowIfNull(value); + + lock (_syncRoot) + { + _bytesRead = Math.Max(_bytesRead, Math.Max(0, value.BytesRead)); + if (value.TotalBytes.HasValue) + { + _totalBytes = Math.Max(_totalBytes ?? 0, Math.Max(0, value.TotalBytes.Value)); + } + + if (_totalBytes.HasValue && _bytesRead > _totalBytes.Value) + { + _totalBytes = _bytesRead; + } + + double? percentage = value.ProgressPercentage; + if (!percentage.HasValue && _totalBytes is > 0) + { + percentage = (double)_bytesRead / _totalBytes.Value * 100D; + } + + if (percentage.HasValue) + { + _percentage = Math.Max( + _percentage ?? 0D, + Math.Clamp(percentage.Value, 0D, 100D)); + } + + PackageUpdateProgress normalized = value with + { + TotalBytes = _totalBytes, + BytesRead = _bytesRead, + ProgressPercentage = _percentage + }; + + // Keep normalization and delivery ordered for concurrent S3 reporters. The caller still owns dispatch + // semantics; the inner reporter posts these ordered values to the UI context. + _inner.Report(normalized); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/PackageInstallFolderReplacer.cs b/GenLauncherGO.Infrastructure/Updating/Support/PackageInstallFolderReplacer.cs new file mode 100644 index 00000000..232770a9 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/PackageInstallFolderReplacer.cs @@ -0,0 +1,322 @@ +using System; +using System.IO; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Replaces an installed package folder through a staged move with rollback and restart recovery. +/// +internal static class PackageInstallFolderReplacer +{ + /// + /// Replaces an installed folder using explicit launcher ownership boundaries. + /// + public static void Replace( + OwnedContentPath temporaryPath, + OwnedContentPath installedPath, + OwnedContentPath backupPath, + ILogger logger) + { + Replace( + temporaryPath, + installedPath, + backupPath, + logger, + ownedBackupPath => OwnedDirectoryTree.DeleteIfExists(ownedBackupPath)); + } + + /// + /// Replaces an installed folder and delegates recovery-backup cleanup through a focused test seam. + /// + internal static void Replace( + OwnedContentPath temporaryPath, + OwnedContentPath installedPath, + OwnedContentPath backupPath, + ILogger logger, + Action deleteBackup) + { + ArgumentNullException.ThrowIfNull(temporaryPath); + ArgumentNullException.ThrowIfNull(installedPath); + ArgumentNullException.ThrowIfNull(backupPath); + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(deleteBackup); + + EnsureRecoveryPathDoesNotOverlapContent(temporaryPath, installedPath, backupPath); + + string temporaryFolderPath = temporaryPath.FullPath; + string installedFolderPath = installedPath.FullPath; + EnsureParentDirectoryExists( + installedPath, + "Installed package paths", + "launcher-owned content", + logger); + + EnsureDirectoryPathHasNoReparsePoints( + installedPath, + "Installed package paths", + logger); + EnsureDirectoryPathHasNoReparsePoints( + backupPath, + "Package backup paths", + logger); + ReconcilePreviousReplacement(installedPath, backupPath, logger, deleteBackup); + EnsureDirectoryPathHasNoReparsePoints( + temporaryPath, + "Temporary package paths", + logger); + + if (!Directory.Exists(temporaryFolderPath)) + { + throw new DirectoryNotFoundException( + $"Temporary package folder '{temporaryFolderPath}' was not found."); + } + + bool backupCreated = false; + try + { + EnsureDirectoryPathHasNoReparsePoints( + temporaryPath, + "Temporary package paths", + logger); + EnsureDirectoryPathHasNoReparsePoints( + installedPath, + "Installed package paths", + logger); + EnsureDirectoryPathHasNoReparsePoints( + backupPath, + "Package backup paths", + logger); + if (Directory.Exists(installedFolderPath)) + { + EnsureParentDirectoryExists( + backupPath, + "Package backup paths", + "launcher-owned recovery state", + logger); + logger.LogDebug( + "Moving existing installed package folder {InstalledFolderName} to a staged backup.", + Path.GetFileName(installedFolderPath)); + Directory.Move(installedFolderPath, backupPath.FullPath); + backupCreated = true; + } + + EnsureDirectoryPathHasNoReparsePoints( + temporaryPath, + "Temporary package paths", + logger); + logger.LogDebug( + "Moving temporary package folder {TemporaryFolderName} into installed package location {InstalledFolderName}.", + Path.GetFileName(temporaryFolderPath), + Path.GetFileName(installedFolderPath)); + Directory.Move(temporaryFolderPath, installedFolderPath); + } + catch + { + RollBackReplacement( + temporaryFolderPath, + installedFolderPath, + backupPath, + backupCreated, + logger); + throw; + } + + if (!backupCreated) + { + return; + } + + try + { + deleteBackup(backupPath); + DeleteEmptyBackupParents(backupPath, logger); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException) + { + logger.LogWarning( + exception, + "Package replacement committed, but obsolete recovery-state cleanup failed for {InstalledFolderName}. Any durable recovery backup will be reconciled on the next replacement.", + Path.GetFileName(installedFolderPath)); + } + } + + /// + /// Resolves the durable backup left by an interrupted or committed replacement before another mutation. + /// + private static void ReconcilePreviousReplacement( + OwnedContentPath installedPath, + OwnedContentPath backupPath, + ILogger logger, + Action deleteBackup) + { + if (!Directory.Exists(backupPath.FullPath)) + { + return; + } + + if (!Directory.Exists(installedPath.FullPath)) + { + logger.LogWarning( + "Restoring interrupted package replacement for {InstalledFolderName} from its recovery backup.", + Path.GetFileName(installedPath.FullPath)); + Directory.Move(backupPath.FullPath, installedPath.FullPath); + DeleteEmptyBackupParents(backupPath, logger); + return; + } + + logger.LogInformation( + "Removing stale recovery backup for committed package {InstalledFolderName}.", + Path.GetFileName(installedPath.FullPath)); + deleteBackup(backupPath); + if (Directory.Exists(backupPath.FullPath)) + { + throw new IOException( + $"The stale recovery backup for package '{Path.GetFileName(installedPath.FullPath)}' could not be removed."); + } + + DeleteEmptyBackupParents(backupPath, logger); + } + + /// + /// Rejects recovery paths that overlap installed content or temporary staging. + /// + private static void EnsureRecoveryPathDoesNotOverlapContent( + OwnedContentPath temporaryPath, + OwnedContentPath installedPath, + OwnedContentPath backupPath) + { + if (PathsOverlap(backupPath.FullPath, installedPath.FullPath) || + PathsOverlap(backupPath.FullPath, temporaryPath.FullPath)) + { + throw new ArgumentException( + "Package recovery backup paths must not overlap installed or temporary package content.", + nameof(backupPath)); + } + } + + private static bool PathsOverlap(string firstPath, string secondPath) + { + return LexicalPath.IsPathInDirectory(firstPath, secondPath) || + LexicalPath.IsPathInDirectory(secondPath, firstPath); + } + + /// + /// Creates an owned parent directory only after verifying its existing path chain is not linked. + /// + private static void EnsureParentDirectoryExists( + OwnedContentPath directoryPath, + string pathSubject, + string ownerDescription, + ILogger logger) + { + try + { + string parentDirectory = Path.GetDirectoryName(directoryPath.FullPath) + ?? throw new InvalidDataException( + $"{pathSubject} must have a parent directory."); + FileSystemPathSafety.ResolveOwnedSubpath( + directoryPath.OwnerRoot, + parentDirectory, + pathSubject, + ownerDescription); + Directory.CreateDirectory(parentDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(parentDirectory, pathSubject); + } + catch (InvalidDataException exception) + { + logger.LogWarning( + exception, + "Blocked package folder replacement because the parent path for {FolderName} failed path-safety validation.", + Path.GetFileName(directoryPath.FullPath)); + + // Surface the check's own message rather than a fixed one, so the IOException names the safety rule + // that actually rejected the path. + throw new IOException(exception.Message, exception); + } + } + + /// + /// Verifies that an install or staging directory path does not cross or contain links before replacement. + /// + private static void EnsureDirectoryPathHasNoReparsePoints( + OwnedContentPath directoryPath, + string pathSubject, + ILogger logger) + { + try + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + directoryPath.FullPath, + pathSubject); + if (Directory.Exists(directoryPath.FullPath)) + { + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + directoryPath.FullPath, + pathSubject); + } + } + catch (InvalidDataException exception) + { + logger.LogWarning( + exception, + "Blocked package folder replacement because {FolderName} contains a reparse point.", + Path.GetFileName(directoryPath.FullPath)); + + // Surface the check's own message rather than a fixed one, so the IOException names the safety rule + // that actually rejected the path. + throw new IOException(exception.Message, exception); + } + } + + /// + /// Attempts to restore the prior installed folder after a staged replacement failure. + /// + private static void RollBackReplacement( + string temporaryPath, + string installedPath, + OwnedContentPath backupPath, + bool backupCreated, + ILogger logger) + { + if (!backupCreated || !Directory.Exists(backupPath.FullPath) || Directory.Exists(installedPath)) + { + return; + } + + try + { + logger.LogWarning( + "Rolling back package folder replacement for {InstalledFolderName}.", + Path.GetFileName(installedPath)); + Directory.Move(backupPath.FullPath, installedPath); + DeleteEmptyBackupParents(backupPath, logger); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to roll back package folder replacement for {InstalledFolderName}. Temporary folder exists: {TemporaryFolderExists}", + Path.GetFileName(installedPath), + Directory.Exists(temporaryPath)); + } + } + + /// + /// Prunes the empty backup hierarchy, including its exclusive ownership boundary when no backups remain. + /// + private static void DeleteEmptyBackupParents(OwnedContentPath backupPath, ILogger logger) + { + foreach (string deletedDirectory in OwnedDirectoryTree.DeleteEmptyParentsIncludingRoot( + backupPath.OwnerRoot, + backupPath.FullPath)) + { + logger.LogDebug( + "Deleted empty launcher package recovery folder {BackupFolderName}.", + Path.GetFileName(deletedDirectory)); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/PackageProgressTracker.cs b/GenLauncherGO.Infrastructure/Updating/Support/PackageProgressTracker.cs new file mode 100644 index 00000000..99004dce --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/PackageProgressTracker.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +internal sealed class PackageProgressTracker +{ + private static readonly TimeSpan _reportInterval = TimeSpan.FromMilliseconds(100); + + private readonly Dictionary _itemProgressBytes = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock _progressGate = new(); + + private readonly long _resumedBytes; + private readonly long _startTimestamp; + private readonly TimeProvider _timeProvider; + + private TimeSpan _lastReportElapsed; + private long _lastReportedBytesRead; + private double? _lastReportedPercentage; + private long? _totalBytes; + private long _totalBytesRead; + + /// + /// Creates a tracker for one package transfer. + /// + /// The package's full size, including whatever is already on disk. + /// + /// Bytes already present when this transfer started. They count towards progress, so a resumed download picks + /// up where it stopped instead of restarting at zero, but never towards the transfer rate: nothing moved them + /// during this session, and crediting them would report a speed and an estimate no connection is achieving. + /// + /// The clock used for rate and estimate calculations. + public PackageProgressTracker(long? totalBytes, long resumedBytes = 0, TimeProvider? timeProvider = null) + { + _totalBytes = totalBytes; + _resumedBytes = Math.Max(0, resumedBytes); + _totalBytesRead = _resumedBytes; + _timeProvider = timeProvider ?? TimeProvider.System; + _startTimestamp = _timeProvider.GetTimestamp(); + } + + public void AddExpectedBytes(long bytes) + { + if (bytes <= 0) + { + return; + } + + lock (_progressGate) + { + if (_totalBytes.HasValue) + { + _totalBytes += bytes; + } + } + } + + public void CompleteItemSilently( + string itemName, + long bytesRead) + { + lock (_progressGate) + { + UpdateItemProgress(itemName, bytesRead); + } + } + + public PackageUpdateProgress? Update( + string itemName, + long bytesRead, + bool forceReport = false) + { + lock (_progressGate) + { + UpdateItemProgress(itemName, bytesRead); + + TimeSpan elapsed = _timeProvider.GetElapsedTime(_startTimestamp); + 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.Clamp( + Math.Round((double)_totalBytesRead / totalBytes.Value * 100, 2), + 0D, + 100D); + progressPercentage = Math.Max(_lastReportedPercentage ?? 0D, progressPercentage.Value); + _lastReportedPercentage = progressPercentage; + } + + double? speedBytesPerSecond = null; + TimeSpan? estimatedTimeRemaining = null; + long transferredThisSession = _totalBytesRead - _resumedBytes; + if (elapsed.TotalSeconds > 0.25 && transferredThisSession > 0) + { + speedBytesPerSecond = transferredThisSession / 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); + } + } + + private void UpdateItemProgress(string itemName, long bytesRead) + { + long previousBytesRead = _itemProgressBytes.GetValueOrDefault(itemName); + long normalizedBytesRead = Math.Max(previousBytesRead, Math.Max(0, bytesRead)); + _itemProgressBytes[itemName] = normalizedBytesRead; + _totalBytesRead += normalizedBytesRead - previousBytesRead; + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/PackageStagingFolderCleaner.cs b/GenLauncherGO.Infrastructure/Updating/Support/PackageStagingFolderCleaner.cs new file mode 100644 index 00000000..3a7be0dd --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/PackageStagingFolderCleaner.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Updating.Models; +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 an explicitly launcher-owned staging folder. + /// + public static void ClearDirectory(OwnedContentPath stagingPath, ILogger logger) + { + ArgumentNullException.ThrowIfNull(stagingPath); + ArgumentNullException.ThrowIfNull(logger); + + string stagingRoot = OwnedDirectoryTree.PrepareEmpty(stagingPath.OwnerRoot, stagingPath.FullPath); + + logger.LogDebug( + "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. + /// + public static void DeleteEmptyPackageParents(OwnedContentPath stagingPath, ILogger logger) + { + ArgumentNullException.ThrowIfNull(stagingPath); + ArgumentNullException.ThrowIfNull(logger); + + var packagesDirectory = new DirectoryInfo(stagingPath.OwnerRoot); + if (!string.Equals( + packagesDirectory.Name, + LauncherFileSystemLayout.PackagesFolderName, + StringComparison.OrdinalIgnoreCase) || + packagesDirectory.Parent is null) + { + return; + } + + try + { + foreach (string deletedDirectory in OwnedDirectoryTree.DeleteEmptyParents( + packagesDirectory.Parent.FullName, + stagingPath.FullPath)) + { + logger.LogDebug( + "Deleted empty package staging folder {StagingFolderName}.", + Path.GetFileName(deletedDirectory)); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + logger.LogWarning( + exception, + "Failed to delete empty package staging parents for {StagingFolderName}.", + Path.GetFileName(stagingPath.FullPath)); + } + } + + /// + /// Deletes reparse points from an explicitly launcher-owned staging folder without following them. + /// + public static void RemoveUnsafeLinks( + OwnedContentPath stagingPath, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(stagingPath); + ArgumentNullException.ThrowIfNull(logger); + + bool replacedRootLink = Directory.Exists(stagingPath.FullPath) && + FileSystemPathSafety.IsReparsePoint(stagingPath.FullPath); + string stagingRoot = OwnedDirectoryTree.EnsureRealDirectory( + stagingPath.OwnerRoot, + stagingPath.FullPath); + if (replacedRootLink) + { + logger.LogWarning( + "Removed unsafe staging-root link {StagingFolderName}.", + Path.GetFileName(stagingRoot)); + } + + cancellationToken.ThrowIfCancellationRequested(); + foreach (string deletedPath in OwnedDirectoryTree.DeleteReparsePoints(stagingPath)) + { + cancellationToken.ThrowIfCancellationRequested(); + logger.LogWarning( + "Removed unsafe staging link {EntryName}.", + Path.GetFileName(deletedPath)); + } + } + + /// + /// Deletes staged files that are not expected by the remote manifest from an explicitly owned staging path. + /// + public static void PruneToManifest( + OwnedContentPath stagingPath, + IReadOnlyList files, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(stagingPath); + ArgumentNullException.ThrowIfNull(files); + ArgumentNullException.ThrowIfNull(logger); + + string stagingRoot = OwnedDirectoryTree.EnsureRealDirectory( + stagingPath.OwnerRoot, + stagingPath.FullPath); + RemoveUnsafeLinks(stagingPath, logger, cancellationToken); + + HashSet expectedPaths = BuildExpectedInstalledPaths(stagingRoot, files); + foreach (string filePath in Directory + .EnumerateFiles(stagingRoot, "*", FileSystemPathSafety.CreateRecursiveNoLinksOptions()) + .ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + + string fullPath = LexicalPath.NormalizeFullPath(filePath); + if (expectedPaths.Contains(fullPath)) + { + continue; + } + + File.Delete(fullPath); + logger.LogDebug( + "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); + } + } + } + + private static HashSet BuildExpectedInstalledPaths( + string stagingRoot, + IReadOnlyList files) + { + HashSet expectedPaths = new(StringComparer.OrdinalIgnoreCase); + foreach (RemoteFileManifestEntry file in files) + { + string destinationPath = ManifestPathResolver.ResolveInstalledPath(stagingRoot, file.FileName); + expectedPaths.Add(LexicalPath.NormalizeFullPath(destinationPath)); + } + + return expectedPaths; + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/S3CatalogDefaults.cs b/GenLauncherGO.Infrastructure/Updating/Support/S3CatalogDefaults.cs new file mode 100644 index 00000000..35f87978 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/S3CatalogDefaults.cs @@ -0,0 +1,74 @@ +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. +/// +internal 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. + /// + public static S3ObjectManifestRequest CreateManifestRequest(LauncherContentVersion 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. + /// + public static string ResolveAccessKey(LauncherContentVersion 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. + /// + public static string ResolveSecretKey(LauncherContentVersion 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..b34c8e23 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/S3HashValidationPolicy.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Determines when S3 package files should be validated with reliable MD5 hashes. +/// +internal static class S3HashValidationPolicy +{ + /// + /// Gets the legacy-compatible file kinds whose manifest MD5 is checked during a normal installation. + /// + public static IReadOnlySet InstallHashCheckedExtensions { get; } = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".w3d", + LauncherContentFileTypes.BigExtension, + ".bik", + LauncherContentFileTypes.GibExtension, + ".dds", + ".tga", + ".ini", + ".scb", + ".wnd", + ".csf", + ".str" + }; + + /// + /// Builds the complete extension set used while repairing an integrity failure already observed on disk. + /// + public static IReadOnlySet CreateRepairHashCheckedExtensions( + IEnumerable files) + { + ArgumentNullException.ThrowIfNull(files); + + var extensions = files + .Select(file => Path.GetExtension(file.FileName)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + extensions.Add(LauncherContentFileTypes.GibExtension); + return extensions; + } + + /// + /// Returns whether a manifest entry should be validated with MD5. + /// + 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. + /// + 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..55703a7d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/S3ReusablePackageFileCopier.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.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 +{ + private readonly IFileHashService _fileHashService; + + private readonly ILogger _logger; + + public S3ReusablePackageFileCopier( + IFileHashService fileHashService, + ILogger logger) + { + _fileHashService = fileHashService ?? throw new ArgumentNullException(nameof(fileHashService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task CopyUnchangedFilesAsync( + OwnedContentPath sourcePath, + OwnedContentPath destinationPath, + IReadOnlyList repositoryFiles, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(sourcePath); + ArgumentNullException.ThrowIfNull(destinationPath); + ArgumentNullException.ThrowIfNull(repositoryFiles); + + FileSystemPathSafety.ResolveOwnedSubpath( + sourcePath.OwnerRoot, + sourcePath.FullPath, + "Reusable package paths", + "launcher-owned content"); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + sourcePath.FullPath, + "Reusable package paths"); + FileSystemPathSafety.ResolveOwnedSubpath( + destinationPath.OwnerRoot, + destinationPath.FullPath, + "Package staging paths", + "launcher-owned temporary storage"); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + destinationPath.FullPath, + "Package staging paths"); + + Dictionary repositoryFileIndex = BuildRepositoryFileIndex(repositoryFiles); + await CopyReusableDirectoryContentAsync( + sourcePath.FullPath, + destinationPath.FullPath, + repositoryFileIndex, + string.Empty, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Builds a manifest lookup keyed by normalized relative paths and converted .gib aliases. + /// + 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; + + string installedPath = + ManifestPathResolver.NormalizeInstalledPathForManifestIndex(repositoryFile.FileName); + if (!string.Equals(installedPath, normalizedPath, StringComparison.OrdinalIgnoreCase)) + { + repositoryFileIndex[installedPath] = repositoryFile; + } + } + + return repositoryFileIndex; + } + + private async Task CopyReusableDirectoryContentAsync( + string sourceDir, + string destinationDir, + Dictionary repositoryFileIndex, + string pathAddition, + CancellationToken cancellationToken) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + sourceDir, + "Reusable package paths"); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationDir, + "Package staging paths"); + 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); + } + + foreach (DirectoryInfo subDir in directories) + { + cancellationToken.ThrowIfCancellationRequested(); + + await CopyReusableDirectoryContentAsync( + subDir.FullName, + Path.Combine(destinationDir, subDir.Name), + repositoryFileIndex, + ManifestPathResolver.NormalizeForManifestIndex(Path.Combine(pathAddition, subDir.Name)), + cancellationToken).ConfigureAwait(false); + } + } + + private async Task CopyReusableFileAsync( + FileInfo file, + string destinationDir, + Dictionary repositoryFileIndex, + string pathAddition, + CancellationToken cancellationToken) + { + string targetFilePath = ManifestPathResolver.ResolvePath(destinationDir, file.Name); + string relativeFilePath = ManifestPathResolver.NormalizeForManifestIndex( + Path.Combine(pathAddition, file.Name)); + + RemoteFileManifestEntry? repositoryFile = repositoryFileIndex.GetValueOrDefault(relativeFilePath); + if (File.Exists(targetFilePath) || repositoryFile is null) + { + return; + } + + if (!S3HashValidationPolicy.IsReliableMd5Hash(repositoryFile.Hash)) + { + return; + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + file.FullName, + "Reusable package paths"); + // Keep the source handle open so Windows denies writes and replacement until the verified bytes are copied. + await using FileStream sourceStream = file.OpenRead(); + if (repositoryFile.Size != (ulong)sourceStream.Length) + { + return; + } + + string hash = await _fileHashService + .ComputeMd5HashAsync(file.FullName, cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(hash, repositoryFile.Hash, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + file.FullName, + "Reusable package paths"); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationDir, + "Package staging paths"); + 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.TestAnalyzers/GenLauncherGO.TestAnalyzers.csproj b/GenLauncherGO.TestAnalyzers/GenLauncherGO.TestAnalyzers.csproj new file mode 100644 index 00000000..2f231d72 --- /dev/null +++ b/GenLauncherGO.TestAnalyzers/GenLauncherGO.TestAnalyzers.csproj @@ -0,0 +1,14 @@ + + + net10.0 + false + false + + + + + $(MSBuildToolsPath)\Roslyn\bincore\Microsoft.CodeAnalysis.dll + false + + + diff --git a/GenLauncherGO.TestAnalyzers/TestMethodNameConvention.cs b/GenLauncherGO.TestAnalyzers/TestMethodNameConvention.cs new file mode 100644 index 00000000..ff78926e --- /dev/null +++ b/GenLauncherGO.TestAnalyzers/TestMethodNameConvention.cs @@ -0,0 +1,15 @@ +using System.Text.RegularExpressions; + +namespace GenLauncherGO.TestAnalyzers; + +internal static class TestMethodNameConvention +{ + private static readonly Regex _validName = new( + "^[A-Z][A-Za-z0-9]*_[A-Z][A-Za-z0-9]*(?:_[A-Z][A-Za-z0-9]*)?$", + RegexOptions.CultureInvariant); + + public static bool IsValid(string name) + { + return _validName.IsMatch(name); + } +} diff --git a/GenLauncherGO.TestAnalyzers/TestMethodNamingAnalyzer.cs b/GenLauncherGO.TestAnalyzers/TestMethodNamingAnalyzer.cs new file mode 100644 index 00000000..556d6ce4 --- /dev/null +++ b/GenLauncherGO.TestAnalyzers/TestMethodNamingAnalyzer.cs @@ -0,0 +1,65 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace GenLauncherGO.TestAnalyzers; + +/// +/// Enforces the repository's behavior-oriented xUnit test naming convention. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class TestMethodNamingAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "GLT001"; + + private static readonly DiagnosticDescriptor _rule = new( + DiagnosticId, + "Test method name must describe behavior", + "Test method '{0}' must use 'MemberOrBehavior_ExpectedOutcome' or " + + "'MemberOrBehavior_Scenario_ExpectedOutcome'", + "Naming", + DiagnosticSeverity.Error, + true, + "xUnit test names use two or three PascalCase segments so behavior and expected outcome remain scannable."); + + public override ImmutableArray SupportedDiagnostics => [_rule]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSymbolAction(AnalyzeMethod, SymbolKind.Method); + } + + private static void AnalyzeMethod(SymbolAnalysisContext context) + { + var method = (IMethodSymbol)context.Symbol; + if (!IsXunitTestMethod(method) || TestMethodNameConvention.IsValid(method.Name)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + _rule, + method.Locations[0], + method.Name)); + } + + private static bool IsXunitTestMethod(IMethodSymbol method) + { + foreach (AttributeData attribute in method.GetAttributes()) + { + for (INamedTypeSymbol? attributeType = attribute.AttributeClass; + attributeType is not null; + attributeType = attributeType.BaseType) + { + if (attributeType.ToDisplayString() == "Xunit.FactAttribute") + { + return true; + } + } + } + + return false; + } +} diff --git a/GenLauncherGO.Tests/AGENTS.md b/GenLauncherGO.Tests/AGENTS.md new file mode 100644 index 00000000..089864c9 --- /dev/null +++ b/GenLauncherGO.Tests/AGENTS.md @@ -0,0 +1,63 @@ +# GenLauncherGO.Tests Guidance + +- Prefer a small handwritten fake over a substitute when it makes stateful behavior clearer. +- Test observable behavior, safety, compatibility mappings, and important invariants. Do not test auto-properties, + standard guards, framework behavior, private helpers, or DI descriptors individually. +- Keep headless Avalonia tests semantic: verify compiled AXAML loads and that meaningful user states expose the expected + content, actions, accessibility, and theme resources. Protect exact appearance with the smallest practical rendered or + golden-image coverage, not assertions over coordinates, margins, grid positions, control dimensions, template-part + structure, or internal visual-tree shape. +- Do not use real-time animation midpoint assertions, no-throw framework smoke tests, or one test per obvious property, + factory, or guard. Test application-owned state transitions and outcomes instead. +- Keep one focused composition test; do not mirror every registration. +- Use isolated temporary directories for file-system tests. Never require a real game installation, live network + service, or production credential. +- Protect exact remote YAML binding and its single mapping into normalized concepts with representative fixtures. +- Reuse shared builders, fakes, the Avalonia headless UI runner, and canonical authorities instead of copying setup or + expected constants. +- Structure tests as arrange, act, and assert separated by blank lines. Add phase comments only when a boundary is + genuinely ambiguous; repeated act phases or branching assertions normally mean the behavior should be split. +- Use surviving mutants to find missing behavior assertions; do not add assertions whose only purpose is raising a score. +- Reach for a shared helper in `Testing/` before writing setup. `GlobalUsings.cs` already imports that namespace, so + no `using` is needed. Add a helper there only once a second caller exists. + +## Naming helpers in `Testing/` + +The prefix states what the helper does, so a reader knows from the call site whether it holds state, answers fixed +values, or is there to assert on. + +| Prefix | Means | +| --- | --- | +| `Fake` | A hand-written working implementation, simplified but with real behavior and state. | +| `Recording` | Captures the calls a test asserts on, exposed as `List<>` properties. | +| `Stub` | Answers with fixed values and records nothing. | +| `Controllable` | The test decides when the operation completes, usually through a `TaskCompletionSource`. | +| `Test` | Builds inputs — paths, content, view models. Not a test double. | + +A helper whose own name says more than the prefix would keeps that name instead: `CompletedGameProcessLaunchOperation`, +`QueueHttpMessageHandler`, `ManualTimeProvider`. Scopes that restore state on dispose end in `Scope`. + +## Mutation testing + +`eng/mutation.proj` runs one Stryker configuration per production area, each with its own break threshold, so a +weakly covered area cannot hide behind a strong one. Together they cover every behavior-bearing file in Core and +Infrastructure. + +`GenLauncherGO.UI` has no configuration and cannot have one: Avalonia emits `InitializeComponent` and the `x:Name` +backing fields from a Roslyn source generator, and Stryker recompiles from parsed syntax trees without running +generators, so every `.axaml.cs` fails with CS0103 and the run aborts. Do not add a UI configuration expecting it to +work. UI quality rests on the coverage backstop and on behavioral tests. + +Some mutants stay alive on purpose. Before adding an assertion to kill one, classify it: + +- **Equivalent** — the mutated program is genuinely indistinguishable, e.g. `new UTF8Encoding(false)` versus `true`, + because `GetBytes` never emits a preamble. Leave it. +- **Unobservable** — real but undetectable from a behavior test: durability flags such as `Flush(true)`, buffer sizes, + `FileOptions` bit combinations, `File.Replace` metadata flags, and exception message text. Asserting message strings + is barred above, so these stay. Leave them. +- **Needs a production change** — an unreachable branch or a missing seam. Raise it as its own decision; do not + contort a test around it. +- **Killable** — the mutation changes something a caller or user observes. This is the only kind worth work. + +`ignore-methods` in each configuration already filters logging, argument guards, and `ConfigureAwait`; the thresholds +account for the residue that cannot be filtered. diff --git a/GenLauncherGO.Tests/Conventions/AsynchronousMethodNamingTests.cs b/GenLauncherGO.Tests/Conventions/AsynchronousMethodNamingTests.cs new file mode 100644 index 00000000..7bb0e6b1 --- /dev/null +++ b/GenLauncherGO.Tests/Conventions/AsynchronousMethodNamingTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Infrastructure.Logging; +using GenLauncherGO.UI.Features.Startup; + +namespace GenLauncherGO.Tests.Conventions; + +/// +/// Holds the Async suffix on awaitable-returning methods across the production assemblies. +/// +/// +/// The `dotnet_naming_rule` in .editorconfig only reaches methods carrying the `async` keyword, +/// because a naming style cannot inspect a return type. A method that returns a task without +/// being declared `async` — a forwarder, or one ending in a single `return SomethingAsync(...)` — +/// is invisible to it, and no CA rule covers the gap either. Reflection does, so the convention +/// is enforced here rather than left to reviewers. +/// +public sealed class AsynchronousMethodNamingTests +{ + /// + /// The methods named for the task they return rather than for work to await. + /// + /// + /// Such a method is a synchronous accessor handing back a handle to work already in flight. Naming + /// GetActiveDownloadTask as GetActiveDownloadTaskAsync would claim the getter itself is + /// asynchronous, which is the opposite of what it does. The exemption is written out member by member so a + /// newly added ...Task method has to be justified rather than excused by its suffix. + /// + private static readonly string[] _taskHandleAccessors = + [ + "GenLauncherGO.UI.Features.Integrity.LauncherPackageActivityService.GetActiveDownloadTask" + ]; + + [Fact] + public void EveryAwaitableReturningProductionMethod_EndsWithAsync() + { + var offenders = ScanAwaitableReturningMethods() + .Where(method => !method.EndsWith("Async", StringComparison.Ordinal)) + .Where(method => !_taskHandleAccessors.Contains(method, StringComparer.Ordinal)) + .ToList(); + + offenders.Should().BeEmpty( + "a method returning Task or ValueTask must end in Async so callers can see it needs awaiting"); + } + + [Fact] + public void NamingScan_CoversEveryAwaitableShapeItClaimsToGuard() + { + // A scan that silently matched nothing would let the test above pass while checking nothing at all, and + // one that missed a task shape would leave that shape unguarded just as quietly. + Type[] awaitableReturnTypes = [typeof(Task), typeof(Task), typeof(ValueTask), typeof(ValueTask)]; + Type[] otherReturnTypes = [typeof(void), typeof(IAsyncEnumerable)]; + + List awaitableReturningMethods = ScanAwaitableReturningMethods(); + + awaitableReturningMethods.Should().Contain( + "GenLauncherGO.Infrastructure.Mods.Services.LauncherContentCatalogService.InitDataAsync", + "the scan must reach the production assemblies it guards"); + awaitableReturnTypes.Should().OnlyContain(returnType => ReturnsAwaitable(returnType)); + otherReturnTypes.Should().NotContain(returnType => ReturnsAwaitable(returnType)); + } + + /// + /// Lists every awaitable-returning production method a person named, as + /// Namespace.Type.Method. + /// + private static List ScanAwaitableReturningMethods() + { + Assembly[] productionAssemblies = + [ + typeof(LexicalPath).Assembly, + typeof(SensitiveDataRedactingTextFormatter).Assembly, + typeof(LauncherApplicationHost).Assembly + ]; + + return productionAssemblies + .SelectMany(assembly => assembly.GetTypes()) + .Where(type => !IsCompilerGenerated(type)) + .SelectMany(type => type + .GetMethods( + BindingFlags.Public | + BindingFlags.NonPublic | + BindingFlags.Instance | + BindingFlags.Static | + BindingFlags.DeclaredOnly) + .Where(method => ReturnsAwaitable(method.ReturnType)) + .Where(method => !IsCompilerGenerated(method)) + // Property accessors, event add/remove, and operators cannot carry a suffix. + .Where(method => !method.IsSpecialName) + .Select(method => $"{type.FullName}.{method.Name}")) + .Order(StringComparer.Ordinal) + .ToList(); + } + + private static bool ReturnsAwaitable(Type returnType) + { + if (returnType == typeof(Task) || returnType == typeof(ValueTask)) + { + return true; + } + + if (!returnType.IsGenericType) + { + return false; + } + + Type openReturnType = returnType.GetGenericTypeDefinition(); + return openReturnType == typeof(Task<>) || openReturnType == typeof(ValueTask<>); + } + + private static bool IsCompilerGenerated(MemberInfo member) + { + // Async state machines, iterators, and local functions are emitted as members whose names + // the author never chose, so they are outside the convention. + return member.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false) || + member.Name.Contains('<', StringComparison.Ordinal); + } +} diff --git a/GenLauncherGO.Tests/Conventions/PublicTypeConsumptionTests.cs b/GenLauncherGO.Tests/Conventions/PublicTypeConsumptionTests.cs new file mode 100644 index 00000000..6a0de989 --- /dev/null +++ b/GenLauncherGO.Tests/Conventions/PublicTypeConsumptionTests.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Runtime.CompilerServices; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Infrastructure.Logging; +using GenLauncherGO.UI.Features.Startup; + +namespace GenLauncherGO.Tests.Conventions; + +/// +/// Holds public visibility to types another production project actually consumes. +/// +/// +/// Consumption is read from the consuming assembly's TypeRef table rather than from its public +/// signatures. A type used only as a local inside a method body appears in the former and in +/// none of the latter, so signature scanning would report live types as unused — and a gate +/// that accuses wrongly teaches people to add exclusions, which is the drift being prevented. +/// The test suite is deliberately not counted as a consumer: every production assembly grants +/// it InternalsVisibleTo, so needing it from a test is not a reason to be public. +/// +public sealed class PublicTypeConsumptionTests +{ + [Fact] + public void EveryPublicCoreType_IsConsumedByInfrastructureOrUi() + { + HashSet consumed = ReadReferencedTypeNames(InfrastructureAssembly); + consumed.UnionWith(ReadReferencedTypeNames(UiAssembly)); + + AssertNoUnconsumedPublicTypes(CoreAssembly, consumed, "Infrastructure or UI"); + } + + [Fact] + public void EveryPublicInfrastructureType_IsConsumedByUi() + { + AssertNoUnconsumedPublicTypes( + InfrastructureAssembly, + ReadReferencedTypeNames(UiAssembly), + "UI"); + } + + [Fact] + public void ConsumptionAnalysis_ReadsBothSidesOfTheBoundary() + { + // A metadata read that silently returned nothing would let both tests above pass while + // checking nothing at all, which is the failure a convention test has to rule out + // explicitly rather than assume. + CoreAssembly.GetExportedTypes().Should().NotBeEmpty("Core must expose types to analyse"); + InfrastructureAssembly.GetExportedTypes().Should().NotBeEmpty( + "Infrastructure must expose types to analyse"); + ReadReferencedTypeNames(InfrastructureAssembly).Should().NotBeEmpty( + "Infrastructure must record the types it references"); + ReadReferencedTypeNames(UiAssembly).Should().NotBeEmpty( + "UI must record the types it references"); + } + + private static Assembly CoreAssembly => typeof(LexicalPath).Assembly; + + private static Assembly InfrastructureAssembly => typeof(SensitiveDataRedactingTextFormatter).Assembly; + + private static Assembly UiAssembly => typeof(LauncherApplicationHost).Assembly; + + private static void AssertNoUnconsumedPublicTypes( + Assembly producer, + HashSet consumedTypeNames, + string consumerDescription) + { + List offenders = producer.GetExportedTypes() + .Where(type => !type.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false)) + // A nested type is referenced through its declaring type, so the outermost type is what + // a consumer's metadata records. + .Select(GetOutermostType) + .Select(type => type.FullName) + .Where(name => name != null) + .Distinct(StringComparer.Ordinal) + .Where(name => !consumedTypeNames.Contains(name!)) + .Order(StringComparer.Ordinal) + .ToList()!; + + offenders.Should().BeEmpty( + "a type is public only when another production project consumes it, but {0} exposes {1} " + + "that {2} never references:{3}{4}", + producer.GetName().Name, + offenders.Count, + consumerDescription, + Environment.NewLine, + string.Join(Environment.NewLine, offenders)); + } + + private static Type GetOutermostType(Type type) + { + Type outermost = type; + while (outermost.DeclaringType != null) + { + outermost = outermost.DeclaringType; + } + + return outermost; + } + + /// + /// Reads every type the assembly references, including types touched only inside method bodies. + /// + private static HashSet ReadReferencedTypeNames(Assembly assembly) + { + var referencedTypeNames = new HashSet(StringComparer.Ordinal); + + using FileStream stream = File.OpenRead(assembly.Location); + using PEReader peReader = new(stream); + MetadataReader metadata = peReader.GetMetadataReader(); + + foreach (TypeReferenceHandle handle in metadata.TypeReferences) + { + TypeReference typeReference = metadata.GetTypeReference(handle); + string namespaceName = metadata.GetString(typeReference.Namespace); + string typeName = metadata.GetString(typeReference.Name); + + referencedTypeNames.Add(string.IsNullOrEmpty(namespaceName) + ? typeName + : $"{namespaceName}.{typeName}"); + } + + return referencedTypeNames; + } +} diff --git a/GenLauncherGO.Tests/Conventions/TestMethodNameConventionTests.cs b/GenLauncherGO.Tests/Conventions/TestMethodNameConventionTests.cs new file mode 100644 index 00000000..3f33c9aa --- /dev/null +++ b/GenLauncherGO.Tests/Conventions/TestMethodNameConventionTests.cs @@ -0,0 +1,26 @@ +using GenLauncherGO.TestAnalyzers; + +namespace GenLauncherGO.Tests.Conventions; + +public sealed class TestMethodNameConventionTests +{ + [Theory] + [InlineData("Member_ExpectedOutcome")] + [InlineData("Member_Scenario_ExpectedOutcome")] + [InlineData("Http2_UsesVersion2")] + public void IsValid_WithBehaviorOrScenarioName_ReturnsTrue(string name) + { + TestMethodNameConvention.IsValid(name).Should().BeTrue(); + } + + [Theory] + [InlineData("Member")] + [InlineData("member_ExpectedOutcome")] + [InlineData("Member_expectedOutcome")] + [InlineData("Member__ExpectedOutcome")] + [InlineData("Member_Scenario_ExpectedOutcome_Extra")] + public void IsValid_WithMalformedName_ReturnsFalse(string name) + { + TestMethodNameConvention.IsValid(name).Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/Core/IO/LexicalPathTests.cs b/GenLauncherGO.Tests/Core/IO/LexicalPathTests.cs new file mode 100644 index 00000000..de68fe3c --- /dev/null +++ b/GenLauncherGO.Tests/Core/IO/LexicalPathTests.cs @@ -0,0 +1,176 @@ +using System; +using System.IO; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Tests.Core.IO; + +public sealed class LexicalPathTests +{ + [Fact] + public void AreEquivalent_NormalizesCaseTrailingSeparatorsAndDotSegments() + { + using TestDirectory directory = new(); + string equivalentPath = Path.Combine(directory.Path.ToUpperInvariant(), ".", "Child", ".."); + + bool result = LexicalPath.AreEquivalent( + directory.Path.ToLowerInvariant() + Path.DirectorySeparatorChar, + equivalentPath); + + result.Should().BeTrue(); + } + + [Theory] + [InlineData(null, @"C:\Games\ZeroHour")] + [InlineData(@"C:\Games\ZeroHour", null)] + [InlineData("", @"C:\Games\ZeroHour")] + [InlineData(" ", @"C:\Games\ZeroHour")] + [InlineData(@"C:\Games\Generals", @"C:\Games\ZeroHour")] + public void AreEquivalent_WhenEitherPathIsMissingOrDifferent_ReturnsFalse( + string? left, + string? right) + { + LexicalPath.AreEquivalent(left, right).Should().BeFalse(); + } + + [Fact] + public void AreEquivalent_WhenAPathIsMalformed_ReturnsFalse() + { + const string MalformedPath = "invalid\0path"; + + bool result = LexicalPath.AreEquivalent(MalformedPath, MalformedPath); + + result.Should().BeFalse(); + } + + [Fact] + public void Containment_AcceptsRootAndChildWithWindowsCaseSemantics() + { + using TestDirectory directory = new(); + string childPath = Path.Combine(directory.Path, "Child", "file.txt"); + + LexicalPath.IsPathInDirectory(directory.Path.ToUpperInvariant(), directory.Path.ToLowerInvariant()) + .Should().BeTrue(); + LexicalPath.IsPathInDirectory(childPath.ToUpperInvariant(), directory.Path.ToLowerInvariant()) + .Should().BeTrue(); + } + + [Fact] + public void Containment_RejectsSiblingWithMatchingPrefix() + { + using TestDirectory directory = new(); + string siblingPath = directory.Path + "-sibling"; + + bool result = LexicalPath.IsPathInDirectory(siblingPath, directory.Path); + + result.Should().BeFalse(); + } + + [Fact] + public void RelativePaths_SeparatorsAndParentSegments_AreCanonicalAndDistinct() + { + using TestDirectory directory = new(); + string childPath = Path.Combine(directory.Path, "Data", "INI", "GameData.ini"); + string outsidePath = Path.Combine(directory.Path, "..", "Outside", "file.txt"); + + LexicalPath.GetRelativePath(directory.Path, childPath).Should().Be("Data/INI/GameData.ini"); + LexicalPath.RelativePathLeavesRoot(LexicalPath.GetRelativePath(directory.Path, outsidePath)) + .Should().BeTrue(); + LexicalPath.RelativePathLeavesRoot("../Outside/file.txt").Should().BeTrue(); + LexicalPath.RelativePathLeavesRoot("..cache/file.txt").Should().BeFalse(); + } + + [Fact] + public void ResolvePath_NormalizesTraversalWithoutClaimingContainment() + { + using TestDirectory directory = new(); + string resolvedPath = LexicalPath.ResolvePath(directory.Path, "../Outside/file.txt"); + + resolvedPath.Should().Be(Path.GetFullPath(Path.Combine(directory.Path, "..", "Outside", "file.txt"))); + LexicalPath.IsPathInDirectory(resolvedPath, directory.Path).Should().BeFalse(); + } + + [Fact] + public void ResolveContainedPath_AcceptsAChildPath() + { + using TestDirectory directory = new(); + + string result = LexicalPath.ResolveContainedPath( + directory.Path, + "Child/file.txt", + "The path must remain contained."); + + result.Should().Be(Path.Combine(directory.Path, "Child", "file.txt")); + } + + [Fact] + public void ResolveContainedPath_RejectsTraversal() + { + using TestDirectory directory = new(); + + Action act = () => LexicalPath.ResolveContainedPath( + directory.Path, + "../Outside/file.txt", + "The path must remain contained."); + + act.Should().Throw(); + } + + [Fact] + public void NormalizeRelativePath_UsesSlashSeparatorsWithoutOuterSlashes() + { + string result = LexicalPath.NormalizeRelativePath(@"\Data\INI\GameData.ini/"); + + result.Should().Be("Data/INI/GameData.ini"); + } + + [Fact] + public void Containment_AcceptsChildOfADriveRoot() + { + using TestDirectory directory = new(); + + LexicalPath.IsPathInDirectory(@"C:\Games\ZeroHour", @"C:\").Should().BeTrue(); + LexicalPath.IsPathInDirectory(directory.Path, directory.Path + Path.DirectorySeparatorChar) + .Should().BeTrue(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(@"C:\Escape")] + [InlineData("file:stream")] + [InlineData("na|me")] + [InlineData(" LexicalPath.NormalizePathSegment(segment, nameof(segment)); + + act.Should().Throw() + .WithParameterName(nameof(segment)); + } + + [Theory] + [InlineData("ABC1")] + [InlineData("COM0")] + [InlineData("COM12")] + [InlineData("COMX")] + [InlineData("LPT0")] + [InlineData("LPT10")] + [InlineData("LPTX")] + public void NormalizePathSegment_AcceptsNamesThatOnlyResembleReservedDevices(string segment) + { + string result = LexicalPath.NormalizePathSegment(segment, nameof(segment)); + + result.Should().Be(segment); + } +} diff --git a/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityReportTests.cs b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityReportTests.cs new file mode 100644 index 00000000..364c19f2 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityReportTests.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Tests.Core.Integrity.Models; + +public sealed class ContentIntegrityReportTests +{ + [Fact] + public void ConstructorDefensively_CopiesIssues() + { + List issues = + [ + new ContentIntegrityIssue( + "target", + "Target", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair, + "file.bin") + ]; + + ContentIntegrityReport report = new(issues); + issues.Clear(); + + report.Issues.Should().ContainSingle(); + } + + [Fact] + public void IssueFlags_ReflectActionableIssueKinds() + { + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + "legacy", + "Legacy", + ContentSourceKind.UnknownLegacy, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.TrustAsManual, + "legacy.big"), + new ContentIntegrityIssue( + "blocking", + "Blocking", + ContentSourceKind.UnknownLegacy, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + ".") + }); + + report.HasIssues.Should().BeTrue(); + report.HasUnknownLegacyIssues.Should().BeTrue(); + report.HasBlockingIssues.Should().BeTrue(); + } + + [Fact] + public void IssueFlags_WithOnlyRepairableIssues_ReportNeitherLegacyNorBlocking() + { + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + "repairable", + "Repairable", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair, + "file.bin") + }); + + report.HasIssues.Should().BeTrue(); + report.HasUnknownLegacyIssues.Should().BeFalse(); + report.HasBlockingIssues.Should().BeFalse(); + } + + [Fact] + public void IssueFlags_WithoutIssues_ReportNoIssues() + { + ContentIntegrityReport report = new([]); + + report.HasIssues.Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityTargetTests.cs b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityTargetTests.cs new file mode 100644 index 00000000..d857c2a2 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityTargetTests.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Tests.Core.Integrity.Models; + +public sealed class ContentIntegrityTargetTests +{ + [Fact] + public void ConstructorDefensively_CopiesIgnoredPaths() + { + HashSet ignoredPaths = new(StringComparer.OrdinalIgnoreCase) + { + "inactive.png" + }; + + ContentIntegrityTarget target = new( + "target", + "Target", + "content", + ContentSourceKind.ManagedS3, + ignoredPaths); + ignoredPaths.Clear(); + + target.IgnoredRelativePaths.Should().Contain("inactive.png"); + } + + [Fact] + public void Constructor_IgnoredPaths_CanonicalizesWithWindowsSemantics() + { + ContentIntegrityTarget target = new( + "target", + "Target", + "content", + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.Ordinal) { @"\Inactive\FILE.PNG/" }); + + target.IgnoredRelativePaths.Contains("inactive/file.png").Should().BeTrue(); + } +} diff --git a/GenLauncherGO.Tests/Core/Integrity/Models/ContentSourceKindTests.cs b/GenLauncherGO.Tests/Core/Integrity/Models/ContentSourceKindTests.cs new file mode 100644 index 00000000..4f46ba0a --- /dev/null +++ b/GenLauncherGO.Tests/Core/Integrity/Models/ContentSourceKindTests.cs @@ -0,0 +1,20 @@ +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Tests.Core.Integrity.Models; + +public sealed class ContentSourceKindTests +{ + [Theory] + [InlineData(ContentSourceKind.UnknownLegacy, false)] + [InlineData(ContentSourceKind.ManagedS3, true)] + [InlineData(ContentSourceKind.ManagedSingleFile, true)] + [InlineData(ContentSourceKind.Manual, false)] + public void IsManagedRemote_ClassifiesRestorableSources( + ContentSourceKind sourceKind, + bool expected) + { + bool isManagedRemote = sourceKind.IsManagedRemote(); + + isManagedRemote.Should().Be(expected); + } +} diff --git a/GenLauncherGO.Tests/Core/Launching/LauncherGameArgumentServiceTests.cs b/GenLauncherGO.Tests/Core/Launching/LauncherGameArgumentServiceTests.cs new file mode 100644 index 00000000..89cee3a2 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Launching/LauncherGameArgumentServiceTests.cs @@ -0,0 +1,119 @@ +using GenLauncherGO.Core.Launching; + +namespace GenLauncherGO.Tests.Core.Launching; + +public sealed class LauncherGameArgumentServiceTests +{ + [Fact] + public void SetArgumentEnabled_AddsArgumentWhenMissing() + { + string arguments = "-foo"; + + string result = LauncherGameArgumentService.SetArgumentEnabled( + arguments, + LauncherGameArgumentService.WindowedArgument, + true); + + result.Should().Be("-foo -win"); + } + + [Fact] + public void SetArgumentEnabled_DoesNotDuplicateExistingArgument() + { + string arguments = "-foo -WIN"; + + string result = LauncherGameArgumentService.SetArgumentEnabled( + arguments, + LauncherGameArgumentService.WindowedArgument, + true); + + result.Should().Be("-foo -WIN"); + } + + [Fact] + public void SetArgumentEnabled_RemovesStandaloneArgumentAndKeepsOtherArguments() + { + string arguments = "-foo \"bar baz\" -win -quickstart"; + + string result = LauncherGameArgumentService.SetArgumentEnabled( + arguments, + LauncherGameArgumentService.WindowedArgument, + false); + + result.Should().Be("-foo \"bar baz\" -quickstart"); + } + + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData(" ", "")] + [InlineData(" -foo -win ", "-foo")] + public void SetArgumentEnabled_WhenDisablingArgument_NormalizesSurroundingWhitespace( + string? arguments, + string expected) + { + string result = LauncherGameArgumentService.SetArgumentEnabled( + arguments, + LauncherGameArgumentService.WindowedArgument, + false); + + result.Should().Be(expected); + } + + [Fact] + public void SetArgumentEnabled_RemovesCompleteQuotedArgument() + { + string result = LauncherGameArgumentService.SetArgumentEnabled( + "\"-win\" -quickstart", + LauncherGameArgumentService.WindowedArgument, + false); + + result.Should().Be("-quickstart"); + } + + [Fact] + public void ContainsArgument_RequiresStandaloneArgument() + { + string arguments = "-windowed -quickstart"; + + bool containsWindowed = LauncherGameArgumentService.ContainsArgument( + arguments, + LauncherGameArgumentService.WindowedArgument); + bool containsQuickStart = LauncherGameArgumentService.ContainsArgument( + arguments, + LauncherGameArgumentService.QuickStartArgument); + + containsWindowed.Should().BeFalse(); + containsQuickStart.Should().BeTrue(); + } + + [Fact] + public void ContainsArgument_RecognizesCompleteQuotedArgument() + { + bool result = LauncherGameArgumentService.ContainsArgument( + "\"-win\" -quickstart", + LauncherGameArgumentService.WindowedArgument); + + result.Should().BeTrue(); + } + + [Fact] + public void ContainsArgument_DoesNotUnquoteIncompleteQuotedToken() + { + bool result = LauncherGameArgumentService.ContainsArgument( + "\"-win", + LauncherGameArgumentService.WindowedArgument); + + result.Should().BeFalse(); + } + + [Fact] + public void ContainsArgument_DoesNotTreatTrailingQuoteAsAnOpeningQuote() + { + bool result = LauncherGameArgumentService.ContainsArgument( + "--win\"", + LauncherGameArgumentService.WindowedArgument); + + result.Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/Core/Launching/Models/LaunchContentIntegrityResolutionProgressTests.cs b/GenLauncherGO.Tests/Core/Launching/Models/LaunchContentIntegrityResolutionProgressTests.cs new file mode 100644 index 00000000..91720ef5 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Launching/Models/LaunchContentIntegrityResolutionProgressTests.cs @@ -0,0 +1,29 @@ +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Tests.Core.Launching.Models; + +public sealed class LaunchContentIntegrityResolutionProgressTests +{ + [Fact] + public void Package_HasProgressAndIsNotComplete() + { + PackageUpdateProgress packageProgress = new(null, 10, null, "package.zip"); + + var progress = + LaunchContentIntegrityResolutionProgress.Package("target", packageProgress); + + progress.PackageProgress.Should().BeSameAs(packageProgress); + progress.Completed.Should().BeFalse(); + } + + [Fact] + public void Complete_HasNoProgressAndIsComplete() + { + var progress = + LaunchContentIntegrityResolutionProgress.Complete("target"); + + progress.PackageProgress.Should().BeNull(); + progress.Completed.Should().BeTrue(); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentFileTypesTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentFileTypesTests.cs new file mode 100644 index 00000000..256d7c57 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentFileTypesTests.cs @@ -0,0 +1,117 @@ +using System.Collections; +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +/// +/// Pins the single authority for which files the launcher treats as content. +/// +/// +/// Manual import, single-file package updates, the artwork cache, and the pickers a user chooses files with all +/// read these sets. Widening one silently makes the importer extract a file it should have copied; narrowing one +/// leaves a downloaded archive unextracted or an offered file rejected after the user picked it. +/// +public sealed class LauncherContentFileTypesTests +{ + [Theory] + [InlineData("mod.zip")] + [InlineData("mod.rar")] + [InlineData("mod.7z")] + [InlineData("MOD.ZIP")] + [InlineData("MOD.Rar")] + [InlineData("MOD.7Z")] + [InlineData(@"C:\downloads\nested.folder\mod.zip")] + public void IsArchive_WithArchiveExtension_ReturnsTrue(string filePath) + { + LauncherContentFileTypes.IsArchive(filePath).Should().BeTrue(); + } + + [Theory] + [InlineData("asset.big")] + [InlineData("asset.gib")] + [InlineData("readme.txt")] + [InlineData("installer.exe")] + [InlineData("mod.tar")] + [InlineData("mod.gz")] + [InlineData("mod.zipx")] + [InlineData("mod.z")] + [InlineData("zip")] + [InlineData("mod")] + [InlineData("")] + public void IsArchive_WithoutArchiveExtension_ReturnsFalse(string filePath) + { + LauncherContentFileTypes.IsArchive(filePath).Should().BeFalse(); + } + + [Theory] + [InlineData(".png")] + [InlineData(".jpg")] + [InlineData(".jpeg")] + [InlineData(".PNG")] + [InlineData(".JPeG")] + public void IsImage_WithAcceptedArtworkExtension_ReturnsTrue(string extension) + { + LauncherContentFileTypes.IsImage(extension).Should().BeTrue(); + } + + [Theory] + [InlineData(".bmp")] + [InlineData(".gif")] + [InlineData(".webp")] + [InlineData(".png.exe")] + [InlineData("png")] + [InlineData("")] + public void IsImage_WithOtherExtension_ReturnsFalse(string extension) + { + LauncherContentFileTypes.IsImage(extension).Should().BeFalse(); + } + + /// + /// Spells the sets out once. Every other caller asks these properties instead of listing extensions, which is + /// what keeps them consistent but also leaves this the only place a changed set can be noticed — a test that + /// built its expectation from the same properties would move with the change and see nothing. + /// + [Fact] + public void AcceptedFormats_PinTheSetsEveryLayerReadsFromHere() + { + LauncherContentFileTypes.ArchiveExtensions.Should().Equal(".zip", ".rar", ".7z"); + LauncherContentFileTypes.GamePackageExtensions.Should().Equal(".big", ".gib"); + LauncherContentFileTypes.ImageExtensions.Should().Equal(".png", ".jpg", ".jpeg"); + LauncherContentFileTypes.DefaultImageExtension.Should().Be(".png"); + } + + /// + /// A game package is copied into place, not unpacked. If the two sets ever overlapped, manual import would + /// try to extract a .big the game is meant to read directly. + /// + [Fact] + public void GamePackagesAndArchives_DoNotOverlap() + { + LauncherContentFileTypes.GamePackageExtensions.Should() + .NotIntersectWith(LauncherContentFileTypes.ArchiveExtensions); + } + + [Fact] + public void AcceptedFormats_CannotBeChangedByConsumers() + { + AssertReadOnly(LauncherContentFileTypes.ArchiveExtensions); + AssertReadOnly(LauncherContentFileTypes.GamePackageExtensions); + AssertReadOnly(LauncherContentFileTypes.ImageExtensions); + } + + private static void AssertReadOnly(IReadOnlyList extensions) + { + extensions.Should().NotBeAssignableTo(); + + if (extensions is ICollection genericCollection) + { + genericCollection.IsReadOnly.Should().BeTrue(); + } + + if (extensions is IList collection) + { + collection.IsReadOnly.Should().BeTrue(); + } + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentKeyTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentKeyTests.cs new file mode 100644 index 00000000..f37017f9 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentKeyTests.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class LauncherContentKeyTests +{ + [Fact] + public void VersionIdentity_DeduplicatesCaseInsensitiveMatchesInHashCollections() + { + LauncherContentVersion first = TestLauncherContent.Version( + "ShockWave", + "1.2", + ModificationType.Addon, + "Parent"); + LauncherContentVersion duplicate = TestLauncherContent.Version( + "shockwave", + "1.2", + ModificationType.Addon, + "parent"); + var keys = new HashSet + { + first.ContentKey, + duplicate.ContentKey + }; + + keys.Should().ContainSingle().Which.Should().Be(first.ContentKey); + } + + /// + /// Every identity component has to reach both the comparison and the hash. A component the hash leaves out + /// still compares correctly, so equality alone cannot see the omission — while every catalog key that differs + /// only in that component starts landing in one bucket. + /// + [Fact] + public void VersionIdentity_IncludesVersionTypeAndParent() + { + LauncherContentKey key = TestLauncherContent + .Version("Shared", "1.0", ModificationType.Addon, "First").ContentKey; + LauncherContentKey otherName = TestLauncherContent + .Version("Other", "1.0", ModificationType.Addon, "First").ContentKey; + LauncherContentKey otherVersion = TestLauncherContent + .Version("Shared", "2.0", ModificationType.Addon, "First").ContentKey; + LauncherContentKey otherType = TestLauncherContent + .Version("Shared", "1.0", ModificationType.Patch, "First").ContentKey; + LauncherContentKey otherParent = TestLauncherContent + .Version("Shared", "1.0", ModificationType.Addon, "Second").ContentKey; + + key.Should().NotBe(otherName); + key.Should().NotBe(otherVersion); + key.Should().NotBe(otherType); + key.Should().NotBe(otherParent); + key.GetHashCode().Should().NotBe(otherName.GetHashCode()); + key.GetHashCode().Should().NotBe(otherVersion.GetHashCode()); + key.GetHashCode().Should().NotBe(otherType.GetHashCode()); + key.GetHashCode().Should().NotBe(otherParent.GetHashCode()); + } + + [Fact] + public void MissingIdentityText_RetainsEmptyStringComparisonSemantics() + { + var missingText = new LauncherContentKey(ModificationType.Mod, null, null, null); + var emptyText = new LauncherContentKey( + ModificationType.Mod, + string.Empty, + string.Empty, + string.Empty); + LauncherContentKey defaultKey = default; + + missingText.Should().Be(emptyText); + defaultKey.Should().Be(emptyText); + defaultKey.GetHashCode().Should().Be(emptyText.GetHashCode()); + missingText.ParentIdentity.Should().BeEmpty(); + missingText.Name.Should().BeEmpty(); + missingText.Version.Should().BeEmpty(); + missingText.HasName(null).Should().BeTrue(); + LauncherContentKey.OriginalGame.HasName("original game").Should().BeTrue(); + } + + [Fact] + public void OriginalGameIdentity_IsStableAndMatchesLegacyCasing() + { + LauncherContentKey originalGame = LauncherContentKey.OriginalGame; + var originalGamePatch = new LauncherContentKey( + ModificationType.Patch, + "Original game", + "GenPatcher", + "1.0"); + + originalGame.ContentType.Should().Be(ModificationType.Mod); + originalGame.ParentIdentity.Should().BeEmpty(); + originalGame.Name.Should().Be("Original Game"); + originalGame.Version.Should().BeEmpty(); + originalGamePatch.IsChildOf(originalGame).Should().BeTrue(); + } + + [Fact] + public void StableString_PreservesExistingIntegrityIdentityFormat() + { + var key = new LauncherContentKey( + ModificationType.Addon, + "ShockWave Patch", + "Music Pack", + "V1.2"); + + key.ToStableString().Should().Be("addon:shockwave patch:music pack:v1.2"); + } + + [Fact] + public void ModificationNameIdentity_SetsOnlyTheModificationName() + { + var key = LauncherContentKey.ForModificationName("ShockWave"); + + key.ContentType.Should().Be(ModificationType.Mod); + key.ParentIdentity.Should().BeEmpty(); + key.Name.Should().Be("ShockWave"); + key.Version.Should().BeEmpty(); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentTests.cs new file mode 100644 index 00000000..d80c802f --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentTests.cs @@ -0,0 +1,302 @@ +using System; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class LauncherContentTests +{ + [Fact] + public void AddOrUpdate_KeepsOneCanonicalVersionAndCombinesLocalStateWithRemoteMetadata() + { + var localState = new LauncherContentInstallation + { + Installed = true, + ContentSourceKind = ContentSourceKind.UnknownLegacy + }; + var localVersion = new LauncherContentVersion(localState) + { + Name = "ShockWave", + Version = "1.2", + ModificationType = ModificationType.Mod, + SimpleDownloadLink = "https://example.test/local-package.zip", + ModDBLink = "https://example.test/local-moddb", + S3BucketName = "local-mods" + }; + var remoteState = new LauncherContentInstallation + { + IsSelected = true, + ContentSourceKind = ContentSourceKind.ManagedSingleFile + }; + var remoteVersion = new LauncherContentVersion(remoteState) + { + Name = "shockwave", + Version = "1.2", + ModificationType = ModificationType.Mod, + SimpleDownloadLink = "https://example.test/package.zip", + ModDBLink = "https://example.test/moddb", + S3BucketName = "remote-mods" + }; + + LauncherContent content = TestLauncherContent.From(localVersion, remoteVersion); + + LauncherContentVersion merged = content.Versions.Should().ContainSingle().Which; + merged.ContentKey.Should().Be(localVersion.ContentKey); + merged.Name.Should().Be("ShockWave"); + merged.SimpleDownloadLink.Should().Be("https://example.test/local-package.zip"); + merged.ModDBLink.Should().Be("https://example.test/local-moddb"); + merged.S3BucketName.Should().Be("local-mods"); + merged.Installation.Should().BeSameAs(localState); + merged.Installation.Installed.Should().BeTrue(); + merged.Installation.IsSelected.Should().BeTrue(); + merged.EffectiveContentSourceKind.Should().Be(ContentSourceKind.ManagedSingleFile); + content.IsSelected.Should().BeTrue(); + content.Installed.Should().BeTrue(); + } + + [Fact] + public void AddOrUpdate_WhenLocalStateIsUnclassified_AdoptsTheIncomingSourceKind() + { + LauncherContentVersion localVersion = TestLauncherContent.Version( + version: "1.2", + installed: true, + sourceKind: ContentSourceKind.UnknownLegacy); + LauncherContentVersion remoteVersion = TestLauncherContent.Version( + version: "1.2", + sourceKind: ContentSourceKind.Manual); + + LauncherContent content = TestLauncherContent.From(localVersion, remoteVersion); + + content.Versions.Should().ContainSingle() + .Which.EffectiveContentSourceKind.Should().Be(ContentSourceKind.Manual); + } + + [Fact] + public void AddOrUpdate_WhenLocalStateIsAlreadyClassified_KeepsItOverAnUnclassifiedIncoming() + { + LauncherContentVersion localVersion = TestLauncherContent.Version( + version: "1.2", + installed: true, + sourceKind: ContentSourceKind.Manual); + LauncherContentVersion remoteVersion = TestLauncherContent.Version( + version: "1.2", + sourceKind: ContentSourceKind.UnknownLegacy); + + LauncherContent content = TestLauncherContent.From(localVersion, remoteVersion); + + content.Versions.Should().ContainSingle() + .Which.EffectiveContentSourceKind.Should().Be(ContentSourceKind.Manual); + } + + [Fact] + public void AddOrUpdate_WhenBothStatesAreClassified_KeepsTheLocalClassification() + { + LauncherContentVersion localVersion = TestLauncherContent.Version( + version: "1.2", + installed: true, + sourceKind: ContentSourceKind.Manual); + LauncherContentVersion remoteVersion = TestLauncherContent.Version( + version: "1.2", + sourceKind: ContentSourceKind.ManagedSingleFile); + + LauncherContent content = TestLauncherContent.From(localVersion, remoteVersion); + + content.Versions.Should().ContainSingle() + .Which.EffectiveContentSourceKind.Should().Be(ContentSourceKind.Manual); + } + + [Fact] + public void AddOrUpdate_WhenLocalRecordHasNoCatalogMetadata_PreservesEveryIncomingFieldAndLocalState() + { + var localInstallation = new LauncherContentInstallation + { + Installed = true, + ContentSourceKind = ContentSourceKind.UnknownLegacy + }; + var localVersion = new LauncherContentVersion(localInstallation) + { + ModificationType = ModificationType.Patch, + Name = "Patch", + Version = "2.0", + ParentContentName = "ShockWave" + }; + var theme = new LauncherContentTheme { GenLauncherActiveColor = "#FF123456" }; + var incomingVersion = new LauncherContentVersion + { + ModificationType = ModificationType.Patch, + Name = "Patch", + Version = "2.0", + ParentContentName = "ShockWave", + SimpleDownloadLink = "https://example.test/package.zip", + UIImageSourceLink = "https://example.test/image.png", + DiscordLink = "https://example.test/discord", + ModDBLink = "https://example.test/moddb", + NewsLink = "https://example.test/news", + S3HostLink = "https://s3.example.test", + S3BucketName = "packages", + S3FolderName = "shockwave/patch/2.0", + S3HostPublicKey = "public-key", + S3HostSecretKey = "secret-key", + NetworkInfo = "Community service required.", + Deprecated = true, + SupportLink = "https://example.test/support", + Theme = theme + }; + + LauncherContent content = TestLauncherContent.From(localVersion, incomingVersion); + + LauncherContentVersion merged = content.Versions.Should().ContainSingle().Which; + merged.Should().BeEquivalentTo( + incomingVersion, + options => options.Excluding(version => version.Installation)); + merged.Installation.Should().BeSameAs(localInstallation); + merged.Installation.Installed.Should().BeTrue(); + merged.EffectiveContentSourceKind.Should().Be(ContentSourceKind.ManagedS3); + } + + [Fact] + public void AddOrUpdate_WhenRemoteMetadataRefreshesExistingVersion_KeepsPublishedTheme() + { + LauncherContentVersion localVersion = TestLauncherContent.Version(version: "1.2", installed: true); + var publishedTheme = new LauncherContentTheme { GenLauncherActiveColor = "#FF123456" }; + LauncherContentVersion remoteVersion = TestLauncherContent.Version(version: "1.2", theme: publishedTheme); + + LauncherContent content = TestLauncherContent.From(localVersion, remoteVersion); + + content.Versions.Should().ContainSingle().Which.Theme.Should().BeSameAs(publishedTheme); + } + + [Fact] + public void AddOrUpdate_WhenRemoteMetadataCarriesNoTheme_KeepsTheAlreadyPublishedTheme() + { + var publishedTheme = new LauncherContentTheme { GenLauncherActiveColor = "#FF123456" }; + LauncherContentVersion localVersion = TestLauncherContent.Version( + version: "1.2", + installed: true, + theme: publishedTheme); + LauncherContentVersion remoteVersion = TestLauncherContent.Version(version: "1.2"); + + LauncherContent content = TestLauncherContent.From(localVersion, remoteVersion); + + content.Versions.Should().ContainSingle().Which.Theme.Should().BeSameAs(publishedTheme); + } + + [Fact] + public void AddOrUpdate_WhenBothVersionsPublishATheme_KeepsTheRemoteTheme() + { + var localTheme = new LauncherContentTheme { GenLauncherActiveColor = "#FF111111" }; + var remoteTheme = new LauncherContentTheme { GenLauncherActiveColor = "#FF222222" }; + LauncherContentVersion localVersion = TestLauncherContent.Version( + version: "1.2", + installed: true, + theme: localTheme); + LauncherContentVersion remoteVersion = TestLauncherContent.Version(version: "1.2", theme: remoteTheme); + + LauncherContent content = TestLauncherContent.From(localVersion, remoteVersion); + + content.Versions.Should().ContainSingle().Which.Theme.Should().BeSameAs(remoteTheme); + } + + [Fact] + public void AddOrMergeVersion_WithVersionFromAnotherCard_Throws() + { + LauncherContent content = TestLauncherContent.From(TestLauncherContent.Version(version: "1.2")); + LauncherContentVersion otherCardVersion = TestLauncherContent.Version("Rise Of The Reds", "1.2"); + + Action act = () => content.AddOrMergeVersion(otherCardVersion); + + act.Should().Throw() + .WithParameterName("version"); + } + + [Fact] + public void LatestVersion_IsTheCardPresentationMetadataAuthority() + { + LauncherContentVersion earliest = new() + { + Name = "ShockWave", + Version = "1.0", + ModificationType = ModificationType.Mod, + SupportLink = "https://example.test/old" + }; + LauncherContentVersion latest = new() + { + Name = "ShockWave", + Version = "2.0", + ModificationType = ModificationType.Mod, + SupportLink = "https://example.test/current" + }; + + LauncherContent content = TestLauncherContent.From(earliest, latest); + + content.LatestVersion.Should().BeSameAs(latest); + content.LatestVersion.SupportLink.Should().Be("https://example.test/current"); + } + + [Fact] + public void SelectedVersion_UsesPersistedInstalledSelectionBeforeFallbacks() + { + LauncherContentVersion earliestInstalled = TestLauncherContent.Version(version: "1.0", installed: true); + LauncherContentVersion selectedInstalled = TestLauncherContent.Version( + version: "2.0", + installed: true, + isSelected: true); + LauncherContentVersion latestRemote = TestLauncherContent.Version(version: "3.0", isSelected: true); + LauncherContent content = TestLauncherContent.From( + earliestInstalled, + latestRemote, + selectedInstalled); + + LauncherContentVersion? selectedVersion = content.GetSelectedVersion(); + + selectedVersion.Should().BeSameAs(selectedInstalled); + } + + [Fact] + public void SelectedVersion_WithoutASavedSelection_PrefersInstalledOverEarlierCatalogVersions() + { + LauncherContentVersion earliestCatalogVersion = TestLauncherContent.Version(version: "1.0"); + LauncherContentVersion earliestInstalled = TestLauncherContent.Version(version: "2.0", installed: true); + LauncherContentVersion laterInstalled = TestLauncherContent.Version(version: "3.0", installed: true); + LauncherContent content = TestLauncherContent.From( + earliestCatalogVersion, + earliestInstalled, + laterInstalled); + + LauncherContentVersion? selectedVersion = content.GetSelectedVersion(); + + selectedVersion.Should().BeSameAs(earliestInstalled); + } + + [Fact] + public void SelectedVersion_FallsBackToEarliestInstalledThenEarliestKnownVersion() + { + LauncherContentVersion latestRemote = TestLauncherContent.Version(version: "3.0"); + LauncherContentVersion earliestInstalled = TestLauncherContent.Version(version: "1.0", installed: true); + LauncherContentVersion laterInstalled = TestLauncherContent.Version(version: "2.5", installed: true); + LauncherContentVersion middleRemote = TestLauncherContent.Version(version: "2.0"); + LauncherContent installedContent = TestLauncherContent.From( + latestRemote, + earliestInstalled, + laterInstalled, + middleRemote); + LauncherContent remoteContent = TestLauncherContent.From(latestRemote, middleRemote); + + installedContent.GetSelectedVersion().Should().BeSameAs(earliestInstalled); + remoteContent.GetSelectedVersion().Should().BeSameAs(middleRemote); + } + + [Fact] + public void LatestInstalledVersion_UsesCanonicalVersionOrdering() + { + LauncherContentVersion latestInstalled = TestLauncherContent.Version(version: "2.0", installed: true); + LauncherContentVersion earliestInstalled = TestLauncherContent.Version(version: "1.0", installed: true); + LauncherContentVersion remoteUpdate = TestLauncherContent.Version(version: "3.0"); + LauncherContent content = TestLauncherContent.From( + latestInstalled, + remoteUpdate, + earliestInstalled); + + content.LatestInstalledVersion.Should().BeSameAs(latestInstalled); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentThemeTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentThemeTests.cs new file mode 100644 index 00000000..f9e59d1c --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentThemeTests.cs @@ -0,0 +1,69 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class LauncherContentThemeTests +{ + [Fact] + public void ThemeSlots_ThatAModificationDidNotPublish_AreEmptyRatherThanNull() + { + LauncherContentTheme theme = new(); + + theme.GenLauncherBorderColor.Should().BeEmpty(); + theme.GenLauncherInactiveBorder.Should().BeEmpty(); + theme.GenLauncherInactiveBorder2.Should().BeEmpty(); + theme.GenLauncherActiveColor.Should().BeEmpty(); + theme.GenLauncherDarkFillColor.Should().BeEmpty(); + theme.GenLauncherDarkBackGround.Should().BeEmpty(); + theme.GenLauncherLightBackGround.Should().BeEmpty(); + theme.GenLauncherDefaultTextColor.Should().BeEmpty(); + theme.GenLauncherDownloadTextColor.Should().BeEmpty(); + theme.GenLauncherListBoxSelectionColor1.Should().BeEmpty(); + theme.GenLauncherListBoxSelectionColor2.Should().BeEmpty(); + theme.GenLauncherButtonSelectionColor.Should().BeEmpty(); + theme.GenLauncherBackgroundImageLink.Should().BeEmpty(); + } + + [Fact] + public void HasValues_WhenNoUsableValueWasPublished_ReturnsFalse() + { + LauncherContentTheme empty = new(); + var whitespace = new LauncherContentTheme { GenLauncherActiveColor = " " }; + + empty.HasValues.Should().BeFalse(); + whitespace.HasValues.Should().BeFalse(); + } + + [Fact] + public void HasValues_WhenPaletteOrArtworkWasPublished_ReturnsTrue() + { + var palette = new LauncherContentTheme { GenLauncherActiveColor = "#102030" }; + var artwork = new LauncherContentTheme + { + GenLauncherBackgroundImageLink = "https://cdn.example.test/background.png" + }; + + palette.HasValues.Should().BeTrue(); + artwork.HasValues.Should().BeTrue(); + } + + /// + /// Pins the cache names the whole launcher agrees on. Every other caller — the download cache, the tile + /// presenter, content removal, and integrity scanning — asks these methods instead of spelling the suffixes + /// out, which is what keeps them consistent but also what leaves this the only place a changed suffix can be + /// noticed. A test that built its expectation from the same call would move with the change and see nothing. + /// + [Fact] + public void CacheBaseNames_KeepArtworkAndPaletteApartFromTheTileImage() + { + const string Version = "1.2"; + + string backgroundBaseName = LauncherContentTheme.ResolveBackgroundImageBaseName(Version); + string paletteBaseName = LauncherContentTheme.ResolveCacheBaseName(Version); + + backgroundBaseName.Should().Be("1.2-background"); + paletteBaseName.Should().Be("1.2-theme"); + backgroundBaseName.Should().NotBe(Version, "the tile image is cached under the bare version"); + paletteBaseName.Should().NotBe(Version, "the tile image is cached under the bare version"); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentVersionTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentVersionTests.cs new file mode 100644 index 00000000..0df2574e --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentVersionTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class LauncherContentVersionTests +{ + [Theory] + [InlineData("", "", 0)] + [InlineData("", "release", 0)] + [InlineData("release", "beta", 0)] + [InlineData("0", "release", 1)] + [InlineData("1", "2", -1)] + [InlineData("1.2", "1.20", 0)] + [InlineData("1.2-beta", "1.2", 0)] + [InlineData("1.2-rc1", "1.2-rc2", -1)] + [InlineData("009", "1.2", -1)] + public void VersionComparer_PreservesLegacyNumericProjection( + string left, + string right, + int expectedSign) + { + var leftVersion = new LauncherContentVersion { Version = left }; + var rightVersion = new LauncherContentVersion { Version = right }; + + int comparison = leftVersion.CompareTo(rightVersion); + + Math.Sign(comparison).Should().Be(expectedSign); + } + + /// + /// The comparer is declared as over a nullable string, so a missing label is + /// part of the contract it offers rather than an impossible input: it has to order against one the same way + /// it orders a label carrying no digits, instead of throwing part-way through sorting a card's versions. + /// + [Fact] + public void VersionComparer_WithAMissingLabel_OrdersItAsCarryingNoDigits() + { + IComparer comparer = LauncherContentVersionComparer.Instance; + + comparer.Compare(null, "1.2").Should().BeNegative(); + comparer.Compare("1.2", null).Should().BePositive(); + comparer.Compare(null, null).Should().Be(0); + comparer.Compare(null, "release").Should().Be(0); + } + + [Fact] + public void CompareTo_HandlesVeryLargeDigitSequencesWithoutOverflow() + { + var older = new LauncherContentVersion { Version = new string('8', 1_000) }; + var newer = new LauncherContentVersion { Version = new string('9', 1_000) }; + + int comparison = older.CompareTo(newer); + + comparison.Should().BeNegative(); + } + + [Theory] + [InlineData("https://s3.example.test", "mods", "ShockWave/1.2", "", ContentSourceKind.UnknownLegacy, + ContentSourceKind.ManagedS3)] + [InlineData("", "", "", "https://example.test/package.zip", ContentSourceKind.UnknownLegacy, + ContentSourceKind.ManagedSingleFile)] + [InlineData("", "", "", "", ContentSourceKind.Manual, ContentSourceKind.Manual)] + public void ResolveContentSourceKind_UsesPackageMetadataPrecedence( + string s3HostLink, + string s3BucketName, + string s3FolderName, + string simpleDownloadLink, + ContentSourceKind fallbackSourceKind, + ContentSourceKind expectedSourceKind) + { + ContentSourceKind sourceKind = LauncherContentVersion.ResolveContentSourceKind( + s3HostLink, + s3BucketName, + s3FolderName, + simpleDownloadLink, + fallbackSourceKind); + + sourceKind.Should().Be(expectedSourceKind); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/LauncherDataTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/LauncherDataTests.cs new file mode 100644 index 00000000..0a5ef028 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/LauncherDataTests.cs @@ -0,0 +1,364 @@ +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class LauncherDataTests +{ + [Fact] + public void AddOrUpdate_AddsContentToMatchingCollections() + { + LauncherData launcherData = new(); + + launcherData.AddOrUpdate(TestLauncherContent.Version("Shockwave", type: ModificationType.Mod)); + launcherData.AddOrUpdate(TestLauncherContent.Version("Patch", type: ModificationType.Patch)); + launcherData.AddOrUpdate(TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Shockwave")); + launcherData.AddOrUpdate(TestLauncherContent.Version("Orphan Addon", type: ModificationType.Addon)); + + launcherData.Modifications.Select(modification => modification.Name) + .Should() + .ContainSingle() + .Which.Should().Be("Shockwave"); + launcherData.Patches.Should().ContainSingle().Which.Name.Should().Be("Patch"); + launcherData.Addons.Should().ContainSingle().Which.Name.Should().Be("Addon"); + } + + [Fact] + public void AddOrUpdate_MergesMatchingVersionsIntoExistingContentCard() + { + LauncherData launcherData = new(); + LauncherContentVersion installedVersion = TestLauncherContent.Version("Shockwave", installed: true); + LauncherContentVersion selectedVersion = TestLauncherContent.Version("shockwave", isSelected: true); + + launcherData.AddOrUpdate(installedVersion); + launcherData.AddOrUpdate(selectedVersion); + + LauncherContent modification = launcherData.Modifications.Should().ContainSingle().Which; + modification.Versions.Should().ContainSingle(); + modification.Installed.Should().BeTrue(); + modification.IsSelected.Should().BeTrue(); + } + + [Fact] + public void Delete_RemovesMatchingVersionAndDeletesEmptyContentCard() + { + LauncherData launcherData = new(); + LauncherContentVersion versionOne = TestLauncherContent.Version("Shockwave"); + LauncherContentVersion versionTwo = TestLauncherContent.Version("Shockwave", "2.0"); + launcherData.AddOrUpdate(versionOne); + launcherData.AddOrUpdate(versionTwo); + + launcherData.DeleteVersion(versionOne.ContentKey); + launcherData.DeleteVersion(versionTwo.ContentKey); + + launcherData.Modifications.Should().BeEmpty(); + } + + [Fact] + public void DeleteVersion_WhenAnotherVersionRemains_KeepsTheCardAndItsDependentContent() + { + LauncherData launcherData = new(); + LauncherContentVersion firstVersion = TestLauncherContent.Version("Parent"); + LauncherContentVersion secondVersion = TestLauncherContent.Version("Parent", "2.0"); + LauncherContentVersion addon = TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Parent"); + launcherData.AddOrUpdate(firstVersion); + launcherData.AddOrUpdate(secondVersion); + launcherData.AddOrUpdate(addon); + + launcherData.DeleteVersion(secondVersion.ContentKey); + + LauncherContent parent = launcherData.Modifications.Should().ContainSingle().Which; + parent.Versions.Should().ContainSingle().Which.Should().BeSameAs(firstVersion); + launcherData.Addons.Should().ContainSingle(); + } + + [Fact] + public void DeleteVersion_WhenTheContentCardIsMissing_KeepsDependentContent() + { + LauncherData launcherData = new(); + LauncherContentVersion addon = TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Parent"); + launcherData.AddOrUpdate(addon); + + launcherData.DeleteVersion(new LauncherContentKey(ModificationType.Mod, string.Empty, "Parent", "1.0")); + + launcherData.Addons.Should().ContainSingle(); + } + + [Fact] + public void DeleteContent_WhenTheContentCardIsMissing_KeepsDependentContent() + { + LauncherData launcherData = new(); + LauncherContentVersion addon = TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Parent"); + launcherData.AddOrUpdate(addon); + + launcherData.DeleteContent(LauncherContentKey.ForModificationName("Parent")); + + launcherData.Addons.Should().ContainSingle(); + } + + [Fact] + public void AddOrUpdate_KeepsChildCardsWithSameNameUnderDifferentParentsSeparate() + { + LauncherData launcherData = new(); + LauncherContentVersion firstAddon = TestLauncherContent.Version( + "Shared Addon", + type: ModificationType.Addon, + parentContentName: "First"); + LauncherContentVersion secondAddon = TestLauncherContent.Version( + "Shared Addon", + type: ModificationType.Addon, + parentContentName: "Second"); + + launcherData.AddOrUpdate(firstAddon); + launcherData.AddOrUpdate(secondAddon); + + launcherData.Addons.Should().HaveCount(2); + launcherData.Addons.Should().ContainSingle(addon => addon.ContentKey.ParentIdentity == "First"); + launcherData.Addons.Should().ContainSingle(addon => addon.ContentKey.ParentIdentity == "Second"); + } + + [Fact] + public void FindContent_UsesTypeParentNameAndOmitsVersionForCardLookup() + { + LauncherData launcherData = new(); + LauncherContentVersion firstAddon = TestLauncherContent.Version( + "Shared Addon", + "1.0", + ModificationType.Addon, + "First"); + LauncherContentVersion secondAddon = TestLauncherContent.Version( + "Shared Addon", + "2.0", + ModificationType.Addon, + "Second"); + launcherData.AddOrUpdate(firstAddon); + launcherData.AddOrUpdate(secondAddon); + + LauncherContent? found = launcherData.FindContent(new LauncherContentKey( + ModificationType.Addon, + "second", + "shared addon", + "different version")); + + found.Should().NotBeNull(); + found!.ContentKey.ParentIdentity.Should().Be("Second"); + found.Versions.Should().ContainSingle().Which.Should().BeSameAs(secondAddon); + } + + [Fact] + public void DeleteContent_RemovesEveryVersionAndDependentAddonAndPatchCards() + { + LauncherData launcherData = new(); + LauncherContentVersion mod = TestLauncherContent.Version("Parent"); + LauncherContentVersion secondModVersion = TestLauncherContent.Version("Parent", "2.0"); + LauncherContentVersion addon = TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Parent"); + LauncherContentVersion patch = TestLauncherContent.Version( + "Patch", + type: ModificationType.Patch, + parentContentName: "Parent"); + LauncherContentVersion patchAddon = TestLauncherContent.Version( + "Patch Addon", + type: ModificationType.Addon, + parentContentName: "Patch"); + LauncherContentVersion unrelatedAddon = TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Other"); + launcherData.AddOrUpdate(mod); + launcherData.AddOrUpdate(secondModVersion); + launcherData.AddOrUpdate(addon); + launcherData.AddOrUpdate(patch); + launcherData.AddOrUpdate(patchAddon); + launcherData.AddOrUpdate(unrelatedAddon); + + launcherData.DeleteContent(mod.ContentKey); + + launcherData.Modifications.Should().BeEmpty(); + launcherData.Addons.Should().ContainSingle().Which.ContentKey.ParentIdentity.Should().Be("Other"); + launcherData.Patches.Should().BeEmpty(); + } + + [Fact] + public void DeleteAddon_RemovesOnlyMatchingAddonCard() + { + LauncherData launcherData = new(); + LauncherContentVersion addon = TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Shockwave"); + LauncherContentVersion patch = TestLauncherContent.Version("Addon", type: ModificationType.Patch); + launcherData.AddOrUpdate(addon); + launcherData.AddOrUpdate(patch); + + launcherData.DeleteVersion(addon.ContentKey); + + launcherData.Addons.Should().BeEmpty(); + launcherData.Patches.Should().ContainSingle(); + } + + [Fact] + public void DeletePatch_RemovesOnlyMatchingPatchCard() + { + LauncherData launcherData = new(); + LauncherContentVersion addon = TestLauncherContent.Version( + "Patch", + type: ModificationType.Addon, + parentContentName: "Shockwave"); + LauncherContentVersion patch = TestLauncherContent.Version("Patch", type: ModificationType.Patch); + launcherData.AddOrUpdate(addon); + launcherData.AddOrUpdate(patch); + + launcherData.DeleteVersion(patch.ContentKey); + + launcherData.Patches.Should().BeEmpty(); + launcherData.Addons.Should().ContainSingle(); + } + + [Fact] + public void DeletePatchAlso_DeletesDependentAddonCards() + { + LauncherData launcherData = new(); + LauncherContentVersion patch = TestLauncherContent.Version( + "Patch", + type: ModificationType.Patch, + parentContentName: "Shockwave"); + LauncherContentVersion dependentAddon = TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Patch"); + LauncherContentVersion unrelatedAddon = TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Other"); + launcherData.AddOrUpdate(patch); + launcherData.AddOrUpdate(dependentAddon); + launcherData.AddOrUpdate(unrelatedAddon); + + launcherData.DeleteVersion(patch.ContentKey); + + launcherData.Patches.Should().BeEmpty(); + launcherData.Addons.Should().ContainSingle().Which.ContentKey.ParentIdentity.Should().Be("Other"); + } + + /// + /// Advertising has no persistent collection behind it, so every catalog operation has to route on the content + /// type rather than assume one. Removal is included because a card that was never stored still reaches the + /// delete paths when a tile is discarded. + /// + [Fact] + public void AddOrUpdate_DoesNotEmbedAdvertisingInPersistentContentCollections() + { + LauncherData launcherData = new(); + LauncherContentVersion modification = TestLauncherContent.Version("ShockWave"); + LauncherContentVersion advertising = TestLauncherContent.Version( + "Featured", + type: ModificationType.Advertising); + launcherData.AddOrUpdate(modification); + launcherData.AddOrUpdate(advertising); + + launcherData.DeleteVersion(advertising.ContentKey); + launcherData.DeleteContent(advertising.ContentKey); + + launcherData.Modifications.Should().ContainSingle() + .Which.ContentKey.Should().Be(modification.ContentKey.WithoutVersion()); + launcherData.FindContent(advertising.ContentKey).Should().BeNull(); + } + + [Fact] + public void PersistedSelectedModificationQuery_ReturnsSelectedCard() + { + LauncherData launcherData = new(); + LauncherContentVersion selectedVersion = TestLauncherContent.Version("ShockWave", isSelected: true); + launcherData.AddOrUpdate(selectedVersion); + launcherData.AddOrUpdate(TestLauncherContent.Version("ShockWave", "1.1")); + + LauncherContent? selectedModification = launcherData.GetSelectedMod(); + + selectedModification.Should().NotBeNull(); + selectedModification!.Name.Should().Be("ShockWave"); + } + + [Fact] + public void OriginalGameContent_QueriesUseOriginalGameDependenciesWhenNoParentIsSupplied() + { + LauncherData launcherData = new(); + LauncherContentVersion patch = TestLauncherContent.Version( + "Original Patch", + type: ModificationType.Patch, + parentContentName: LauncherContentKey.OriginalGame.Name, + isSelected: true); + LauncherContentVersion originalAddon = TestLauncherContent.Version( + "Original Addon", + "2.0", + ModificationType.Addon, + LauncherContentKey.OriginalGame.Name, + isSelected: true); + LauncherContentVersion patchAddon = TestLauncherContent.Version( + "Patch Addon", + "3.0", + ModificationType.Addon, + "Original Patch", + isSelected: true); + launcherData.AddOrUpdate(patch); + launcherData.AddOrUpdate(originalAddon); + launcherData.AddOrUpdate(patchAddon); + + LauncherContent selectedPatch = launcherData.Patches.Single(); + IReadOnlyList patches = launcherData.GetPatchesFor(null); + IReadOnlyList addons = launcherData.GetAddonsFor(null, selectedPatch); + + patches.Select(item => item.Name).Should().Equal("Original Patch"); + addons.Select(addon => addon.Name).Should().Equal("Original Addon", "Patch Addon"); + } + + [Fact] + public void GetAddonsFor_IncludesPatchDependentAddons() + { + LauncherData launcherData = new(); + LauncherContentVersion modification = TestLauncherContent.Version("ShockWave", isSelected: true); + LauncherContentVersion patch = TestLauncherContent.Version( + "ShockWave Patch", + "1.1", + ModificationType.Patch, + "ShockWave", + isSelected: true); + LauncherContentVersion patchAddon = TestLauncherContent.Version( + "Patch Addon", + "2.0", + ModificationType.Addon, + "ShockWave Patch", + isSelected: true); + launcherData.AddOrUpdate(modification); + launcherData.AddOrUpdate(patch); + launcherData.AddOrUpdate(patchAddon); + launcherData.AddOrUpdate(TestLauncherContent.Version( + "Mod Addon", + "3.0", + ModificationType.Addon, + "ShockWave")); + + LauncherContent selectedModification = launcherData.Modifications.Single(); + LauncherContent selectedPatch = launcherData.Patches.Single(); + IReadOnlyList addons = launcherData.GetAddonsFor( + selectedModification, + selectedPatch); + + addons.Select(addon => addon.Name).Should().Equal("Mod Addon", "Patch Addon"); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/OwnedContentPathTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/OwnedContentPathTests.cs new file mode 100644 index 00000000..01aff290 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/OwnedContentPathTests.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class OwnedContentPathTests +{ + [Fact] + public void Constructor_NormalizesOwnedChildAndExposesRelativePath() + { + string ownerRoot = TestLauncherPaths.CreateVirtualRoot("OwnedContentPath").ModsDirectory; + string fullPath = Path.Combine(ownerRoot, "ShockWave", "..", "ShockWave", "1.2"); + + var result = new OwnedContentPath(ownerRoot, fullPath); + + result.OwnerRoot.Should().Be(ownerRoot); + result.FullPath.Should().Be(Path.Combine(ownerRoot, "ShockWave", "1.2")); + result.RelativePath.Should().Be("ShockWave/1.2"); + } + + [Theory] + [InlineData(".")] + [InlineData(@"..\Outside")] + public void Constructor_RejectsPathOutsideOwnershipBoundary(string relativeFragment) + { + string ownerRoot = TestLauncherPaths.CreateVirtualRoot("OwnershipBoundary").ModsDirectory; + string fullPath = Path.Combine(ownerRoot, relativeFragment); + + Action act = () => new OwnedContentPath(ownerRoot, fullPath); + + act.Should().Throw() + .WithParameterName("fullPath"); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs b/GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs new file mode 100644 index 00000000..d89b36c8 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs @@ -0,0 +1,139 @@ +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 +{ + [Theory] + [InlineData(ModificationType.Mod, null, "Rise Of Reds", "1.9", "Rise Of Reds/1.9")] + [InlineData(ModificationType.Addon, "Rise Of Reds", "Music Pack", "2.0", "Rise Of Reds/Addons/Music Pack/2.0")] + [InlineData(ModificationType.Patch, "Rise Of Reds", "Hotfix", "2.1", "Rise Of Reds/Patches/Hotfix/2.1")] + public void ResolveVersionPath_WhenIdentityIsSupported_ReturnsOwnedVersionDirectory( + ModificationType modificationType, + string? parentContentName, + string name, + string version, + string expectedRelativePath) + { + LauncherPaths paths = CreatePaths(); + LauncherContentKey contentKey = new(modificationType, parentContentName, name, version); + + OwnedContentPath? result = LauncherContentPathResolver.ResolveVersionPath(paths, contentKey); + + AssertOwnedPath(result, paths, expectedRelativePath); + } + + [Fact] + public void ResolveVersionPath_WhenIdentityTypeIsUnsupported_ReturnsNull() + { + LauncherPaths paths = CreatePaths(); + LauncherContentKey contentKey = new(ModificationType.Advertising, null, "News", "1"); + + OwnedContentPath? result = LauncherContentPathResolver.ResolveVersionPath(paths, contentKey); + + result.Should().BeNull(); + } + + [Theory] + [InlineData(ModificationType.Mod, null, "../Escape", "1.0")] + [InlineData(ModificationType.Addon, "../Escape", "Music Pack", "1.0")] + [InlineData(ModificationType.Patch, "Rise Of Reds", "Hotfix", "../Escape")] + [InlineData(ModificationType.Mod, null, "CON", "1.0")] + [InlineData(ModificationType.Mod, null, "Rise Of Reds", "1.0.")] + [InlineData(ModificationType.Addon, @"C:\Escape", "Music Pack", "1.0")] + public void ResolveVersionPath_WhenIdentityIsUnsafeForAPathSegment_Throws( + ModificationType modificationType, + string? parentContentName, + string name, + string version) + { + LauncherPaths paths = CreatePaths(); + LauncherContentKey contentKey = new(modificationType, parentContentName, name, version); + + Action act = () => LauncherContentPathResolver.ResolveVersionPath(paths, contentKey); + + act.Should().Throw(); + } + + [Theory] + [InlineData(ModificationType.Mod, "", "1.0", "")] + [InlineData(ModificationType.Mod, "ShockWave", "", "")] + [InlineData(ModificationType.Addon, "HD", "1.0", "")] + [InlineData(ModificationType.Patch, "Balance", "1.0", "")] + public void ResolveVersionPath_WhenIdentityIsIncomplete_ReturnsNull( + ModificationType modificationType, + string name, + string versionName, + string parentContentName) + { + LauncherPaths paths = CreatePaths(); + LauncherContentKey contentKey = new(modificationType, parentContentName, name, versionName); + + OwnedContentPath? result = LauncherContentPathResolver.ResolveVersionPath(paths, contentKey); + + result.Should().BeNull(); + } + + [Theory] + [InlineData(ModificationType.Mod, null, "ShockWave", "ShockWave", "ShockWave")] + [InlineData(ModificationType.Addon, "ShockWave", "Music", "ShockWave/Addons/Music", "ShockWave")] + [InlineData(ModificationType.Patch, "ShockWave", "Hotfix", "ShockWave/Patches/Hotfix", "ShockWave")] + public void ResolveContentPaths_WhenIdentityIsSupported_ReturnsOwnedMappedDirectories( + ModificationType modificationType, + string? parentContentName, + string name, + string expectedContentRelativePath, + string expectedCleanupRelativePath) + { + LauncherPaths paths = CreatePaths(); + LauncherContentKey contentKey = new(modificationType, parentContentName, name, "1.0"); + + OwnedContentPath? contentPath = LauncherContentPathResolver.ResolveContentPath(paths, contentKey); + OwnedContentPath? cleanupPath = LauncherContentPathResolver.ResolveCleanupRootPath(paths, contentKey); + + AssertOwnedPath(contentPath, paths, expectedContentRelativePath); + AssertOwnedPath(cleanupPath, paths, expectedCleanupRelativePath); + } + + [Theory] + [InlineData(ModificationType.Mod, null, "")] + [InlineData(ModificationType.Addon, null, "Music")] + [InlineData(ModificationType.Patch, "", "Hotfix")] + [InlineData(ModificationType.Advertising, null, "News")] + public void ResolveContentPaths_WhenIdentityIsIncompleteOrUnsupported_ReturnNull( + ModificationType modificationType, + string? parentContentName, + string name) + { + LauncherPaths paths = CreatePaths(); + LauncherContentKey contentKey = new(modificationType, parentContentName, name, "1.0"); + + OwnedContentPath? contentPath = LauncherContentPathResolver.ResolveContentPath(paths, contentKey); + OwnedContentPath? cleanupPath = LauncherContentPathResolver.ResolveCleanupRootPath(paths, contentKey); + + contentPath.Should().BeNull(); + cleanupPath.Should().BeNull(); + } + + private static void AssertOwnedPath( + OwnedContentPath? path, + LauncherPaths paths, + string expectedRelativePath) + { + string ownerRoot = Path.GetFullPath(paths.ModsDirectory); + string normalizedRelativePath = expectedRelativePath.Replace('/', Path.DirectorySeparatorChar); + + path.Should().NotBeNull(); + path!.OwnerRoot.Should().Be(ownerRoot); + path.FullPath.Should().Be(Path.Combine(ownerRoot, normalizedRelativePath)); + } + + private static LauncherPaths CreatePaths() + { + return TestLauncherPaths.CreateVirtualRoot("LauncherContentPathResolver"); + } +} diff --git a/GenLauncherGO.Tests/Core/Settings/Models/LauncherPreferencesTests.cs b/GenLauncherGO.Tests/Core/Settings/Models/LauncherPreferencesTests.cs new file mode 100644 index 00000000..3fe3dd8e --- /dev/null +++ b/GenLauncherGO.Tests/Core/Settings/Models/LauncherPreferencesTests.cs @@ -0,0 +1,74 @@ +using System; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Settings.Models; + +public sealed class LauncherPreferencesTests +{ + [Theory] + [InlineData(SupportedGame.Generals, SupportedGame.ZeroHour, @"C:\Games\Generals")] + [InlineData(SupportedGame.ZeroHour, SupportedGame.Generals, @"D:\Games\ZeroHour")] + public void WithPath_RoundTripsThroughGetPath(SupportedGame game, SupportedGame otherGame, string path) + { + LauncherInstallations installations = new LauncherInstallations().WithPath(game, path); + + installations.GetPath(game).Should().Be(path); + installations.GetPath(otherGame).Should().BeNull(); + } + + [Fact] + public void InstallationsResolveConfiguredPreferredGame_WithSingleInstallationFallback() + { + var both = new LauncherInstallations + { + Generals = @"C:\Games\Generals", + ZeroHour = @"C:\Games\ZeroHour" + }; + var generalsOnly = new LauncherInstallations { Generals = @"C:\Games\Generals" }; + var zeroHourOnly = new LauncherInstallations { ZeroHour = @"C:\Games\ZeroHour" }; + + both.ResolvePreferredGame(SupportedGame.Generals).Should().Be(SupportedGame.Generals); + both.ResolvePreferredGame(SupportedGame.ZeroHour).Should().Be(SupportedGame.ZeroHour); + both.ResolvePreferredGame(null).Should().BeNull(); + generalsOnly.ResolvePreferredGame(SupportedGame.ZeroHour).Should().Be(SupportedGame.Generals); + zeroHourOnly.ResolvePreferredGame(SupportedGame.Generals).Should().Be(SupportedGame.ZeroHour); + new LauncherInstallations().ResolvePreferredGame(SupportedGame.Generals).Should().BeNull(); + } + + [Theory] + [InlineData(SupportedGame.Generals, SupportedGame.ZeroHour)] + [InlineData(SupportedGame.ZeroHour, SupportedGame.Generals)] + public void With_RoundTripsThroughGet(SupportedGame game, SupportedGame otherGame) + { + var preferences = new LauncherGamePreferences { GameArguments = "-quickstart" }; + LauncherGamePreferencesSet original = new(); + + LauncherGamePreferencesSet games = original.With(game, preferences); + + games.Get(game).Should().BeSameAs(preferences); + games.Get(otherGame).Should().BeSameAs(original.Get(otherGame)); + } + + [Fact] + public void CustomExecutable_TrimsDisplayNameAndNormalizesExecutableName() + { + LauncherCustomExecutable executable = new(" My Client ", " custom.exe "); + + executable.DisplayName.Should().Be("My Client"); + executable.ExecutableName.Should().Be("custom.exe"); + } + + [Theory] + [InlineData(@"tools\custom.exe")] + [InlineData(@"..\custom.exe")] + [InlineData("custom.txt")] + [InlineData("CON.exe")] + public void CustomExecutable_WithUnsafeExecutableName_Throws(string executableName) + { + Action act = () => new LauncherCustomExecutable("My Client", executableName); + + act.Should().Throw() + .WithParameterName(nameof(executableName)); + } +} diff --git a/GenLauncherGO.Tests/Core/Startup/GameInstallationServiceExtensionsTests.cs b/GenLauncherGO.Tests/Core/Startup/GameInstallationServiceExtensionsTests.cs new file mode 100644 index 00000000..69033e24 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/GameInstallationServiceExtensionsTests.cs @@ -0,0 +1,154 @@ +using System; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Core.Startup.Models; + +namespace GenLauncherGO.Tests.Core.Startup; + +public sealed class GameInstallationServiceExtensionsTests +{ + [Fact] + public void ValidateInstallations_ReportsIdenticalEnteredPathsBeforeFilesystemFailures() + { + IGameInstallationService service = Substitute.For(); + service.Validate( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(GameInstallationValidationResult.Invalid( + GameInstallationValidationFailure.DirectoryNotFound)); + + LauncherInstallationsValidationResult result = service.ValidateInstallations( + new LauncherInstallations + { + Generals = @"C:\Missing\Game", + ZeroHour = @" c:\missing\game " + }, + @"C:\Launcher"); + + result.HasDuplicatePath.Should().BeTrue(); + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void ValidateInstallations_WithNoConfiguredPaths_RejectsSet() + { + IGameInstallationService service = CreateService((_, _) => MissingPath()); + + LauncherInstallationsValidationResult result = service.ValidateInstallations( + new LauncherInstallations(), + @"C:\Launcher"); + + result.IsValid.Should().BeFalse(); + result.HasDuplicatePath.Should().BeFalse(); + result.CanonicalInstallations.Should().Be(new LauncherInstallations()); + } + + [Fact] + public void ValidateInstallations_WithOneValidPath_ReturnsCanonicalSet() + { + const string CanonicalPath = @"C:\Games\Generals"; + IGameInstallationService service = CreateService((game, path) => + game == SupportedGame.Generals && !string.IsNullOrWhiteSpace(path) + ? GameInstallationValidationResult.Valid(CanonicalPath) + : MissingPath()); + + LauncherInstallationsValidationResult result = service.ValidateInstallations( + new LauncherInstallations { Generals = @"C:\Games\GENERALS" }, + @"C:\Launcher"); + + result.IsValid.Should().BeTrue(); + result.HasDuplicatePath.Should().BeFalse(); + result.CanonicalInstallations.Should().Be( + new LauncherInstallations { Generals = CanonicalPath }); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ValidateInstallations_WithABlankPath_TreatsItAsNotConfigured(string blankPath) + { + const string ZeroHourPath = @"C:\Games\ZeroHour"; + IGameInstallationService service = CreateService((game, path) => + game == SupportedGame.ZeroHour && !string.IsNullOrWhiteSpace(path) + ? GameInstallationValidationResult.Valid(ZeroHourPath) + : MissingPath()); + + LauncherInstallationsValidationResult result = service.ValidateInstallations( + new LauncherInstallations { Generals = blankPath, ZeroHour = ZeroHourPath }, + @"C:\Launcher"); + + result.IsValid.Should().BeTrue(); + result.CanonicalInstallations.Should().Be( + new LauncherInstallations { ZeroHour = ZeroHourPath }); + } + + [Fact] + public void ValidateInstallations_WithInvalidNonemptyPath_RejectsSet() + { + IGameInstallationService service = CreateService((game, path) => + { + if (string.IsNullOrWhiteSpace(path)) + { + return MissingPath(); + } + + return game == SupportedGame.Generals + ? GameInstallationValidationResult.Valid(@"C:\Games\Generals") + : GameInstallationValidationResult.Invalid( + GameInstallationValidationFailure.DirectoryNotFound); + }); + + LauncherInstallationsValidationResult result = service.ValidateInstallations( + new LauncherInstallations + { + Generals = @"C:\Games\Generals", + ZeroHour = @"C:\Not-Zero-Hour" + }, + @"C:\Launcher"); + + result.IsValid.Should().BeFalse(); + result.CanonicalInstallations.Should().Be( + new LauncherInstallations { Generals = @"C:\Games\Generals" }); + } + + [Fact] + public void ValidateInstallations_WithSameCanonicalPath_RejectsDuplicate() + { + IGameInstallationService service = CreateService((game, _) => + GameInstallationValidationResult.Valid( + game == SupportedGame.Generals ? @"C:\Games\Shared" : @"c:\games\shared")); + + LauncherInstallationsValidationResult result = service.ValidateInstallations( + new LauncherInstallations + { + Generals = @"C:\Games\GeneralsAlias", + ZeroHour = @"C:\Games\ZeroHourAlias" + }, + @"C:\Launcher"); + + result.IsValid.Should().BeFalse(); + result.HasDuplicatePath.Should().BeTrue(); + } + + private static IGameInstallationService CreateService( + Func validation) + { + IGameInstallationService service = Substitute.For(); + service.Validate( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(call => validation( + call.ArgAt(0), + call.ArgAt(1))); + return service; + } + + private static GameInstallationValidationResult MissingPath() + { + return GameInstallationValidationResult.Invalid( + GameInstallationValidationFailure.PathMissing); + } +} diff --git a/GenLauncherGO.Tests/Core/Startup/LauncherFileSystemLayoutTests.cs b/GenLauncherGO.Tests/Core/Startup/LauncherFileSystemLayoutTests.cs new file mode 100644 index 00000000..269796bc --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/LauncherFileSystemLayoutTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Startup; + +public sealed class LauncherFileSystemLayoutTests +{ + [Fact] + public void GetBuiltInGameExecutableNames_ForZeroHour_ListsOnlineThenCommunityThenRetail() + { + IReadOnlyList executableNames = + LauncherFileSystemLayout.GetBuiltInGameExecutableNames(SupportedGame.ZeroHour); + + executableNames.Should().Equal("generalsonlinezh.exe", "generalszh.exe", "generals.exe"); + } + + [Fact] + public void GetBuiltInGameExecutableNames_ForGenerals_ListsCommunityThenRetail() + { + IReadOnlyList executableNames = + LauncherFileSystemLayout.GetBuiltInGameExecutableNames(SupportedGame.Generals); + + executableNames.Should().Equal("generalsv.exe", "generals.exe"); + } + + [Fact] + public void GetBuiltInWorldBuilderExecutableNames_ForZeroHour_ListsRetailThenCommunity() + { + IReadOnlyList executableNames = + LauncherFileSystemLayout.GetBuiltInWorldBuilderExecutableNames(SupportedGame.ZeroHour); + + executableNames.Should().Equal("WorldBuilder.exe", "worldbuilderzh.exe"); + } + + [Fact] + public void GetBuiltInWorldBuilderExecutableNames_ForGenerals_ListsRetailThenCommunity() + { + IReadOnlyList executableNames = + LauncherFileSystemLayout.GetBuiltInWorldBuilderExecutableNames(SupportedGame.Generals); + + executableNames.Should().Equal("WorldBuilder.exe", "worldbuilderv.exe"); + } + + [Theory] + [InlineData(" custom.exe ", "custom.exe")] + [InlineData("Custom.EXE", "Custom.EXE")] + public void NormalizeExecutableFileName_TrimsAndKeepsAnyCaseExeExtension( + string executableName, + string expectedExecutableName) + { + string normalizedName = LauncherFileSystemLayout.NormalizeExecutableFileName(executableName); + + normalizedName.Should().Be(expectedExecutableName); + } + + [Theory] + [InlineData("custom")] + [InlineData("custom.txt")] + [InlineData("custom.exe.txt")] + public void NormalizeExecutableFileName_WithoutExeExtension_Throws(string executableName) + { + Action act = () => LauncherFileSystemLayout.NormalizeExecutableFileName(executableName); + + act.Should().Throw() + .WithParameterName(nameof(executableName)); + } + + [Theory] + [InlineData(@"tools\custom.exe")] + [InlineData("tools/custom.exe")] + [InlineData(@"..\custom.exe")] + [InlineData(@"C:\tools\custom.exe")] + [InlineData("CON.exe")] + [InlineData("")] + public void NormalizeExecutableFileName_WithUnsafeRootLevelName_Throws(string executableName) + { + Action act = () => LauncherFileSystemLayout.NormalizeExecutableFileName(executableName); + + act.Should().Throw() + .WithParameterName(nameof(executableName)); + } +} diff --git a/GenLauncherGO.Tests/Core/Startup/LauncherPathsTests.cs b/GenLauncherGO.Tests/Core/Startup/LauncherPathsTests.cs new file mode 100644 index 00000000..2cdb18b8 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/LauncherPathsTests.cs @@ -0,0 +1,151 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Startup; + +public sealed class LauncherPathsTests +{ + [Fact] + public void Constructor_NormalizesRootsAndDerivesCanonicalLayout() + { + string gameDirectory = Path.Combine("GenLauncherGO.Tests", "Game", "."); + string executableDirectory = Path.Combine("GenLauncherGO.Tests", "Launcher", "."); + + var storagePaths = new LauncherStoragePaths(executableDirectory); + LauncherPaths paths = storagePaths.CreateGamePaths(SupportedGame.ZeroHour, gameDirectory); + string expectedGameRoot = Path.GetFullPath(gameDirectory); + string expectedOwnedGameDataRoot = Path.Combine( + storagePaths.DataDirectory, + "C&C Zero Hour Data"); + + paths.Game.Should().Be(SupportedGame.ZeroHour); + paths.GameDirectory.Should().Be(expectedGameRoot); + paths.OwnedGameDataDirectory.Should().Be(expectedOwnedGameDataRoot); + paths.RuntimeDirectory.Should().Be(Path.Combine(expectedOwnedGameDataRoot, "Runtime")); + paths.CacheDirectory.Should().Be(Path.Combine(expectedOwnedGameDataRoot, "Runtime", "Cache")); + paths.ImagesDirectory.Should().Be(Path.Combine(expectedOwnedGameDataRoot, "Runtime", "Cache", "Images")); + paths.ModsDirectory.Should().Be(Path.Combine(expectedOwnedGameDataRoot, "Mods")); + paths.TempDirectory.Should().Be(Path.Combine(expectedOwnedGameDataRoot, "Runtime", "Temp")); + paths.DeploymentDirectory.Should().Be(Path.Combine(expectedOwnedGameDataRoot, "Runtime", "Deployment")); + paths.StateDirectory.Should().Be(Path.Combine(expectedOwnedGameDataRoot, "Runtime", "State")); + paths.IntegrityDirectory.Should().Be(Path.Combine(expectedOwnedGameDataRoot, "Runtime", "Integrity")); + paths.PackageBackupsDirectory.Should().Be(Path.Combine( + expectedOwnedGameDataRoot, + "Runtime", + "State", + LauncherFileSystemLayout.PackageBackupsFolderName)); + } + + [Fact] + public void GetPackageTemporaryPath_BuildsOwnedPathUnderTempDirectory() + { + LauncherPaths paths = TestLauncherPaths.Create(); + string installedFolderPath = Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"); + + OwnedContentPath temporaryPath = paths.GetPackageTemporaryPath( + new OwnedContentPath(paths.ModsDirectory, installedFolderPath)); + + temporaryPath.OwnerRoot.Should().Be(paths.PackagesDirectory); + temporaryPath.FullPath.Should().Be(Path.Combine(paths.TempDirectory, "Packages", "ShockWave", "1.2")); + } + + [Fact] + public void GetPackageTemporaryPath_UsesFolderNameWhenInstallIsOutsideModsDirectory() + { + LauncherPaths paths = TestLauncherPaths.Create(); + string installedFolderPath = Path.Combine(paths.GameDirectory, "Data"); + + OwnedContentPath temporaryPath = paths.GetPackageTemporaryPath( + new OwnedContentPath(paths.GameDirectory, installedFolderPath)); + + temporaryPath.FullPath.Should().Be(Path.Combine(paths.TempDirectory, "Packages", "Data")); + } + + [Fact] + public void GetPackageTemporaryPath_PreservesModsChildNamesThatStartWithDots() + { + LauncherPaths paths = TestLauncherPaths.Create(); + string installedFolderPath = Path.Combine(paths.ModsDirectory, "..cache", "1.0"); + + OwnedContentPath temporaryPath = paths.GetPackageTemporaryPath( + new OwnedContentPath(paths.ModsDirectory, installedFolderPath)); + + temporaryPath.FullPath.Should().Be(Path.Combine(paths.TempDirectory, "Packages", "..cache", "1.0")); + } + + [Fact] + public void GetPackageBackupPathMirrorsModsRelativePath_UnderDurableStateDirectory() + { + LauncherPaths paths = TestLauncherPaths.Create(); + string installedFolderPath = Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"); + + OwnedContentPath backupPath = paths.GetPackageBackupPath( + new OwnedContentPath(paths.ModsDirectory, installedFolderPath)); + + string backupRoot = Path.Combine( + paths.StateDirectory, + LauncherFileSystemLayout.PackageBackupsFolderName); + backupPath.OwnerRoot.Should().Be(backupRoot); + backupPath.FullPath.Should().Be(Path.Combine(backupRoot, "ShockWave", "1.2")); + } + + [Fact] + public void GetPackageBackupPath_RejectsInstallOutsideModsDirectory() + { + LauncherPaths paths = TestLauncherPaths.Create(); + var installedPath = new OwnedContentPath( + paths.GameDirectory, + Path.Combine(paths.GameDirectory, "Data")); + + Action act = () => paths.GetPackageBackupPath(installedPath); + + act.Should().Throw() + .WithParameterName("installedPath"); + } + + [Fact] + public void LauncherDataFilePath_BuildsPathUnderRuntimeStateDirectory() + { + LauncherPaths paths = TestLauncherPaths.Create(); + + string launcherDataFilePath = paths.LauncherDataFilePath; + + launcherDataFilePath.Should().Be( + Path.Combine(paths.RuntimeDirectory, "State", "LauncherData.yaml")); + } + + [Fact] + public void GetModificationImageFilePath_BuildsPathUnderModificationImageCache() + { + LauncherPaths paths = TestLauncherPaths.Create(); + + string imageFilePath = paths.GetModificationImageFilePath("ShockWave", "1.2.png"); + + imageFilePath.Should().Be(Path.Combine(paths.ImagesDirectory, "ShockWave", "1.2.png")); + } + + [Fact] + public void GetModificationImagesDirectory_ThrowsForPathTraversalModificationName() + { + LauncherPaths paths = TestLauncherPaths.Create(); + + Action act = () => paths.GetModificationImagesDirectory($"..{Path.DirectorySeparatorChar}Escape"); + + act.Should().Throw(); + } + + [Fact] + public void GetModificationImageFilePath_ThrowsForPathTraversalImageFileName() + { + LauncherPaths paths = TestLauncherPaths.Create(); + + Action act = () => paths.GetModificationImageFilePath( + "ShockWave", + $"..{Path.DirectorySeparatorChar}1.2.png"); + + act.Should().Throw(); + } + +} diff --git a/GenLauncherGO.Tests/Core/Startup/LauncherRuntimePathContextTests.cs b/GenLauncherGO.Tests/Core/Startup/LauncherRuntimePathContextTests.cs new file mode 100644 index 00000000..a33de2f3 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/LauncherRuntimePathContextTests.cs @@ -0,0 +1,55 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Startup; + +public sealed class LauncherRuntimePathContextTests +{ + [Fact] + public void SwitchActiveAtomically_ReplacesImmutablePathSnapshot() + { + var storagePaths = new LauncherStoragePaths(@"C:\Launcher"); + LauncherPaths generalsPaths = storagePaths.CreateGamePaths( + SupportedGame.Generals, + @"C:\Games\Generals"); + LauncherPaths zeroHourPaths = storagePaths.CreateGamePaths( + SupportedGame.ZeroHour, + @"D:\Games\Zero Hour"); + var context = new LauncherRuntimePathContext(storagePaths, generalsPaths); + + context.SwitchActive(zeroHourPaths); + + context.ActivePaths.Should().BeSameAs(zeroHourPaths); + context.StoragePaths.Should().BeSameAs(storagePaths); + } + + [Fact] + public void SwitchActive_WithPathsOwnedByAnotherStorageRoot_RejectsAndKeepsActiveSnapshot() + { + var storagePaths = new LauncherStoragePaths(@"C:\Launcher"); + LauncherPaths generalsPaths = storagePaths.CreateGamePaths( + SupportedGame.Generals, + @"C:\Games\Generals"); + LauncherPaths foreignPaths = new LauncherStoragePaths(@"D:\OtherLauncher") + .CreateGamePaths(SupportedGame.ZeroHour, @"D:\Games\Zero Hour"); + var context = new LauncherRuntimePathContext(storagePaths, generalsPaths); + + Action act = () => context.SwitchActive(foreignPaths); + + act.Should().Throw(); + context.ActivePaths.Should().BeSameAs(generalsPaths); + } + + [Fact] + public void Constructor_RejectsPathsOwnedByAnotherLauncherStorageRoot() + { + var storagePaths = new LauncherStoragePaths(@"C:\Launcher"); + LauncherPaths foreignPaths = new LauncherStoragePaths(@"D:\OtherLauncher") + .CreateGamePaths(SupportedGame.ZeroHour, @"C:\Games\Zero Hour"); + + Action act = () => new LauncherRuntimePathContext(storagePaths, foreignPaths); + + act.Should().Throw() + .WithMessage("*canonical per-game data directory*"); + } +} diff --git a/GenLauncherGO.Tests/Core/Startup/LauncherStoragePathsTests.cs b/GenLauncherGO.Tests/Core/Startup/LauncherStoragePathsTests.cs new file mode 100644 index 00000000..6c9add62 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/LauncherStoragePathsTests.cs @@ -0,0 +1,56 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Startup; + +public sealed class LauncherStoragePathsTests +{ + [Theory] + [InlineData(SupportedGame.Generals, "C&C Generals Data")] + [InlineData(SupportedGame.ZeroHour, "C&C Zero Hour Data")] + public void Constructor_DerivesSharedStandaloneAndIsolatedGamePaths( + SupportedGame game, + string expectedGameDataFolderName) + { + string executableDirectory = Path.GetFullPath(Path.Combine("GenLauncherGO.Tests", "Launcher")); + string dataDirectory = Path.Combine( + executableDirectory, + LauncherFileSystemLayout.LauncherDataFolderName); + var storage = new LauncherStoragePaths(executableDirectory); + + LauncherPaths gamePaths = storage.CreateGamePaths( + game, + Path.GetFullPath(Path.Combine("GenLauncherGO.Tests", "Game"))); + + storage.ExecutableDirectory.Should().Be(executableDirectory); + storage.DataDirectory.Should().Be(dataDirectory); + storage.LogsDirectory.Should().Be(Path.Combine(dataDirectory, "Logs")); + storage.PreferencesFilePath.Should().Be(Path.Combine(dataDirectory, "LauncherPreferences.yaml")); + gamePaths.OwnedGameDataDirectory.Should().Be(Path.Combine(dataDirectory, expectedGameDataFolderName)); + } + + [Fact] + public void CreateGamePaths_IsolatesEachSupportedGameDataDirectory() + { + var storage = new LauncherStoragePaths( + Path.GetFullPath(Path.Combine("GenLauncherGO.Tests", "Launcher"))); + string gameDirectory = Path.GetFullPath(Path.Combine("GenLauncherGO.Tests", "Game")); + + LauncherPaths generalsPaths = storage.CreateGamePaths(SupportedGame.Generals, gameDirectory); + LauncherPaths zeroHourPaths = storage.CreateGamePaths(SupportedGame.ZeroHour, gameDirectory); + + generalsPaths.OwnedGameDataDirectory.Should().NotBe(zeroHourPaths.OwnedGameDataDirectory); + } + + [Fact] + public void CreateGamePaths_WithUnsupportedGame_Throws() + { + var storage = new LauncherStoragePaths(Path.GetFullPath("Launcher")); + + Action act = () => storage.CreateGamePaths(SupportedGame.Unknown, Path.GetFullPath("Game")); + + act.Should().Throw() + .WithParameterName("managedGame"); + } +} diff --git a/GenLauncherGO.Tests/Core/Startup/Models/GameInstallationValidationResultTests.cs b/GenLauncherGO.Tests/Core/Startup/Models/GameInstallationValidationResultTests.cs new file mode 100644 index 00000000..9fcc1321 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/Models/GameInstallationValidationResultTests.cs @@ -0,0 +1,17 @@ +using System; +using GenLauncherGO.Core.Startup.Models; + +namespace GenLauncherGO.Tests.Core.Startup.Models; + +public sealed class GameInstallationValidationResultTests +{ + [Fact] + public void Invalid_WithoutAFailure_Throws() + { + Action act = () => GameInstallationValidationResult.Invalid( + GameInstallationValidationFailure.None); + + act.Should().Throw() + .WithParameterName("failure"); + } +} diff --git a/GenLauncherGO.Tests/Core/Updating/Models/PackageDownloadPauseControllerTests.cs b/GenLauncherGO.Tests/Core/Updating/Models/PackageDownloadPauseControllerTests.cs new file mode 100644 index 00000000..89878856 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Updating/Models/PackageDownloadPauseControllerTests.cs @@ -0,0 +1,89 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Tests.Core.Updating.Models; + +public sealed class PackageDownloadPauseControllerTests +{ + [Fact] + public void WaitWhilePausedAsync_WithoutController_CompletesImmediately() + { + ValueTask wait = PackageDownloadPauseController.WaitWhilePausedAsync(null, CancellationToken.None); + + wait.IsCompletedSuccessfully.Should().BeTrue(); + } + + [Fact] + public async Task PauseAndResume_AreIdempotentAndExposeCurrentStateAsync() + { + PackageDownloadPauseController controller = new(); + + controller.IsPaused.Should().BeFalse(); + controller.WaitWhilePausedAsync(CancellationToken.None).AsTask() + .IsCompletedSuccessfully.Should().BeTrue(); + controller.Resume().Should().BeFalse(); + controller.Pause().Should().BeTrue(); + controller.Pause().Should().BeFalse(); + controller.IsPaused.Should().BeTrue(); + + Task waiter = controller.WaitWhilePausedAsync(CancellationToken.None).AsTask(); + waiter.IsCompleted.Should().BeFalse(); + controller.Resume().Should().BeTrue(); + controller.Resume().Should().BeFalse(); + await waiter.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + controller.IsPaused.Should().BeFalse(); + } + + [Fact] + public async Task Resume_ReleasesEveryWaiterFromTheSamePauseAsync() + { + PackageDownloadPauseController controller = new(); + controller.Pause(); + Task[] waiters = Enumerable.Range(0, 16) + .Select(_ => controller.WaitWhilePausedAsync(CancellationToken.None).AsTask()) + .ToArray(); + + waiters.Should().OnlyContain(waiter => !waiter.IsCompleted); + controller.Resume().Should().BeTrue(); + + await Task.WhenAll(waiters).WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + waiters.Should().OnlyContain(waiter => waiter.IsCompletedSuccessfully); + } + + [Fact] + public async Task CancelingOneWaiter_DoesNotResumeOrCancelOtherWaitersAsync() + { + PackageDownloadPauseController controller = new(); + controller.Pause(); + using CancellationTokenSource cancellation = new(); + Task canceledWaiter = controller.WaitWhilePausedAsync(cancellation.Token).AsTask(); + Task remainingWaiter = controller.WaitWhilePausedAsync(CancellationToken.None).AsTask(); + + await cancellation.CancelAsync(); + + Func canceled = () => canceledWaiter; + await canceled.Should().ThrowAsync(); + controller.IsPaused.Should().BeTrue(); + remainingWaiter.IsCompleted.Should().BeFalse(); + controller.Resume().Should().BeTrue(); + await remainingWaiter.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task ConcurrentTransitions_ReportExactlyOneStateChangeAsync() + { + PackageDownloadPauseController controller = new(); + + bool[] pauseResults = await Task.WhenAll( + Enumerable.Range(0, 32).Select(_ => Task.Run(controller.Pause))); + bool[] resumeResults = await Task.WhenAll( + Enumerable.Range(0, 32).Select(_ => Task.Run(controller.Resume))); + + pauseResults.Should().ContainSingle(changed => changed); + resumeResults.Should().ContainSingle(changed => changed); + controller.IsPaused.Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/GenLauncherGO.Tests.csproj b/GenLauncherGO.Tests/GenLauncherGO.Tests.csproj new file mode 100644 index 00000000..828359c3 --- /dev/null +++ b/GenLauncherGO.Tests/GenLauncherGO.Tests.csproj @@ -0,0 +1,59 @@ + + + Exe + net10.0-windows + false + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + 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..e78bfdaf --- /dev/null +++ b/GenLauncherGO.Tests/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using FluentAssertions; +global using GenLauncherGO.Tests.Testing; +global using NSubstitute; +global using Xunit; diff --git a/GenLauncherGO.Tests/Infrastructure/Archives/ArchiveExtractorTests.cs b/GenLauncherGO.Tests/Infrastructure/Archives/ArchiveExtractorTests.cs new file mode 100644 index 00000000..1d4d836d --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Archives/ArchiveExtractorTests.cs @@ -0,0 +1,262 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text; +using System.Threading; +using GenLauncherGO.Infrastructure.Archives; +using SharpCompress.Common; +using SharpCompress.Writers; +using SharpCompress.Writers.SevenZip; + +namespace GenLauncherGO.Tests.Infrastructure.Archives; + +public sealed class ArchiveExtractorTests +{ + [Fact] + public void ExtractToDirectory_PreservesBigFilesByDefault() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.GetPath("extract"); + CreateZipArchive(archivePath, "Data/test.big", "test data"); + + var extractor = new ArchiveExtractor(); + + extractor.ExtractToDirectory(archivePath, destinationDirectory, cancellationToken: TestContext.Current.CancellationToken); + + File.Exists(Path.Combine(destinationDirectory, "Data", "test.big")).Should().BeTrue(); + File.Exists(Path.Combine(destinationDirectory, "Data", "test.gib")).Should().BeFalse(); + } + + [Fact] + public void ExtractToDirectory_ConvertsBigFilesWhenRequested() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.GetPath("extract"); + CreateZipArchive(archivePath, "Data/test.big", "test data"); + + var extractor = new ArchiveExtractor(); + + extractor.ExtractToDirectory(archivePath, destinationDirectory, true, TestContext.Current.CancellationToken); + + File.Exists(Path.Combine(destinationDirectory, "Data", "test.gib")).Should().BeTrue(); + File.Exists(Path.Combine(destinationDirectory, "Data", "test.big")).Should().BeFalse(); + } + + /// + /// Archive entry keys are attacker-controlled, so traversal through either separator and a rooted key all have + /// to be refused before anything is written. + /// + [Theory] + [InlineData("../escape.txt", @"..\escape.txt")] + [InlineData(@"..\escape.txt", @"..\escape.txt")] + [InlineData(@"C:\escape.txt", @"C:\escape.txt")] + public void ExtractToDirectory_RejectsEntriesOutsideDestinationDirectory( + string entryKey, + string escapeTargetPath) + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.GetPath("extract"); + string escapedFilePath = Path.GetFullPath(Path.Combine(destinationDirectory, escapeTargetPath)); + CreateZipArchive(archivePath, entryKey, "escaped"); + + var extractor = new ArchiveExtractor(); + + Action act = () => extractor.ExtractToDirectory(archivePath, destinationDirectory); + + act.Should().Throw() + .WithMessage("*outside the destination folder*"); + File.Exists(escapedFilePath).Should().BeFalse(); + } + + [Fact] + public void ExtractToDirectory_EntryWithoutFileName_RejectsArchive() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.GetPath("extract"); + CreateZipArchive(archivePath, " ", "payload"); + + var extractor = new ArchiveExtractor(); + + Action act = () => extractor.ExtractToDirectory(archivePath, destinationDirectory); + + act.Should().Throw() + .WithMessage("*missing a file name*"); + } + + [Fact] + public void ExtractToDirectory_ExtractsSevenZipArchiveAndConvertsBigFiles() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.7z"); + string destinationDirectory = directory.GetPath("extract"); + CreateSevenZipArchive(archivePath, "Data/test.big", "test data"); + + var extractor = new ArchiveExtractor(); + + extractor.ExtractToDirectory(archivePath, destinationDirectory, true, TestContext.Current.CancellationToken); + + File.ReadAllText(Path.Combine(destinationDirectory, "Data", "test.gib")) + .Should().Be("test data"); + File.Exists(Path.Combine(destinationDirectory, "Data", "test.big")).Should().BeFalse(); + } + + [Fact] + public void ExtractToDirectory_RejectsLinkedDestinationTreeWithoutWritingThroughIt() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.CreateDirectory("extract"); + string linkedDirectory = Path.Combine(destinationDirectory, "linked"); + CreateZipArchive(archivePath, "linked/escape.txt", "escaped"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + linkedDirectory); + var extractor = new ArchiveExtractor(); + + Action act = () => extractor.ExtractToDirectory(archivePath, destinationDirectory); + + act.Should().Throw() + .WithMessage("*reparse point*"); + File.Exists(Path.Combine(junction.TargetDirectory, "escape.txt")).Should().BeFalse(); + Directory.Exists(linkedDirectory).Should().BeTrue(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void ExtractToDirectory_RejectsDestinationBelowLinkedParentBeforeCreatingIt() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string linkedParent = directory.GetPath("linked"); + string destinationDirectory = Path.Combine(linkedParent, "extract"); + CreateZipArchive(archivePath, "readme.txt", "payload"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + linkedParent); + var extractor = new ArchiveExtractor(); + + Action act = () => extractor.ExtractToDirectory(archivePath, destinationDirectory); + + act.Should().Throw() + .WithMessage("*reparse point*"); + Directory.Exists(Path.Combine(junction.TargetDirectory, "extract")).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void ExtractToDirectory_RejectsUnrelatedLinkedChildBeforeWritingSafeEntry() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.CreateDirectory("extract"); + CreateZipArchive(archivePath, "readme.txt", "payload"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(destinationDirectory, "unrelated")); + var extractor = new ArchiveExtractor(); + + Action act = () => extractor.ExtractToDirectory(archivePath, destinationDirectory); + + act.Should().Throw() + .WithMessage("*reparse point*"); + File.Exists(Path.Combine(destinationDirectory, "readme.txt")).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void ExtractToDirectory_PreCanceledTokenDoesNotWriteArchiveEntries() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.GetPath("extract"); + CreateZipArchive(archivePath, "readme.txt", "payload"); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + var extractor = new ArchiveExtractor(); + + Action act = () => extractor.ExtractToDirectory( + archivePath, + destinationDirectory, + cancellationToken: cancellation.Token); + + act.Should().Throw(); + File.Exists(Path.Combine(destinationDirectory, "readme.txt")).Should().BeFalse(); + } + + [Fact] + public void ExtractToDirectory_HandlesExplicitDirectoryEntryAndOverwritesExistingFile() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.CreateDirectory("extract"); + string destinationFilePath = Path.Combine(destinationDirectory, "Data", "readme.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(destinationFilePath)!); + File.WriteAllText(destinationFilePath, "old contents"); + using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + archive.CreateEntry("Data/"); + ZipArchiveEntry entry = archive.CreateEntry("Data/readme.txt"); + using Stream entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream); + writer.Write("replacement"); + } + + var extractor = new ArchiveExtractor(); + + extractor.ExtractToDirectory(archivePath, destinationDirectory, cancellationToken: TestContext.Current.CancellationToken); + + File.ReadAllText(destinationFilePath).Should().Be("replacement"); + } + + /// + /// Extracted package files keep the modification time the archive recorded. Deployment copies that timestamp + /// onto the game-facing file and the modification image cache keys on it, so stamping extraction time instead + /// would rewrite both. + /// + [Fact] + public void ExtractToDirectory_PreservesTheArchiveEntryTimestamp() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.GetPath("extract"); + DateTime entryTimestamp = new(2020, 1, 2, 3, 4, 4, DateTimeKind.Unspecified); + using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry("Data/readme.txt"); + entry.LastWriteTime = new DateTimeOffset(entryTimestamp, TimeSpan.Zero); + using Stream entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream); + writer.Write("payload"); + } + + var extractor = new ArchiveExtractor(); + + extractor.ExtractToDirectory(archivePath, destinationDirectory, cancellationToken: TestContext.Current.CancellationToken); + + File.GetLastWriteTime(Path.Combine(destinationDirectory, "Data", "readme.txt")) + .Should().Be(entryTimestamp); + } + + 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); + } + + private static void CreateSevenZipArchive(string archivePath, string entryName, string contents) + { + using IWriter writer = WriterFactory.OpenWriter( + archivePath, + ArchiveType.SevenZip, + new SevenZipWriterOptions()); + using MemoryStream contentsStream = new(Encoding.UTF8.GetBytes(contents)); + writer.Write(entryName, contentsStream, null); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Common/BigFileVariantPathTests.cs b/GenLauncherGO.Tests/Infrastructure/Common/BigFileVariantPathTests.cs new file mode 100644 index 00000000..3a51d2ec --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Common/BigFileVariantPathTests.cs @@ -0,0 +1,133 @@ +using System.IO; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Tests.Infrastructure.Common; + +public sealed class BigFileVariantPathTests +{ + [Fact] + public void VariantMappings_RoundTripCaseInsensitivePackagePathsAndPreserveOtherFiles() + { + string bigPath = Path.Combine("Data", "asset.BIG"); + string installedPath = BigFileVariantPath.GetInstalledPath(bigPath); + + installedPath.Should().Be(Path.Combine("Data", "asset.gib")); + BigFileVariantPath.GetDeploymentPath(installedPath) + .Should().Be(Path.Combine("Data", "asset.big")); + BigFileVariantPath.GetInstalledPath("readme.txt").Should().Be("readme.txt"); + BigFileVariantPath.GetDeploymentPath("readme.txt").Should().Be("readme.txt"); + } + + [Fact] + public void GetExistingDownloadedPath_PrefersRequestedBigPath() + { + using TestDirectory testDirectory = new(); + string bigPath = Path.Combine(testDirectory.Path, "asset.big"); + string gibPath = Path.Combine(testDirectory.Path, "asset.gib"); + File.WriteAllText(bigPath, "big"); + File.WriteAllText(gibPath, "gib"); + + string existingPath = BigFileVariantPath.GetExistingDownloadedPath(bigPath); + + existingPath.Should().Be(bigPath); + } + + [Fact] + public void GetExistingDownloadedPath_FallsBackToConvertedGibPath() + { + using TestDirectory testDirectory = new(); + string bigPath = Path.Combine(testDirectory.Path, "asset.big"); + string gibPath = Path.Combine(testDirectory.Path, "asset.gib"); + File.WriteAllText(gibPath, "gib"); + + string existingPath = BigFileVariantPath.GetExistingDownloadedPath(bigPath); + + existingPath.Should().Be(gibPath); + } + + [Fact] + public void ConvertBigFileToGib_MovesBigFileAndReplacesExistingGib() + { + using TestDirectory testDirectory = new(); + string bigPath = Path.Combine(testDirectory.Path, "asset.big"); + string gibPath = Path.Combine(testDirectory.Path, "asset.gib"); + File.WriteAllText(bigPath, "new"); + File.WriteAllText(gibPath, "old"); + + BigFileVariantPath.ConvertBigFileToGib(bigPath); + + File.Exists(bigPath).Should().BeFalse(); + File.ReadAllText(gibPath).Should().Be("new"); + } + + [Fact] + public void PrepareBigFileResumePath_MovesConvertedGibBackToBigPath() + { + using TestDirectory testDirectory = new(); + string bigPath = Path.Combine(testDirectory.Path, "asset.big"); + string gibPath = Path.Combine(testDirectory.Path, "asset.gib"); + File.WriteAllText(gibPath, "partial"); + + BigFileVariantPath.PrepareBigFileResumePath(bigPath); + + File.ReadAllText(bigPath).Should().Be("partial"); + File.Exists(gibPath).Should().BeFalse(); + } + + /// + /// A partial .big download outranks an installed .gib of the same package, because moving the + /// installed file over it would discard the bytes the resumed transfer is about to append to. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PrepareBigFileResumePath_ReturnsWhenResumeMoveIsNotNeeded(bool installedFileAlsoExists) + { + using TestDirectory testDirectory = new(); + string bigPath = Path.Combine(testDirectory.Path, "asset.big"); + string gibPath = Path.Combine(testDirectory.Path, "asset.gib"); + File.WriteAllText(bigPath, "partial"); + if (installedFileAlsoExists) + { + File.WriteAllText(gibPath, "installed"); + } + + BigFileVariantPath.PrepareBigFileResumePath(bigPath); + + File.ReadAllText(bigPath).Should().Be("partial"); + File.Exists(gibPath).Should().Be(installedFileAlsoExists); + } + + [Fact] + public void BigFileConversions_NonPackageFile_LeaveTheFileUntouched() + { + using TestDirectory testDirectory = new(); + string filePath = Path.Combine(testDirectory.Path, "readme.txt"); + File.WriteAllText(filePath, "readme"); + + BigFileVariantPath.ConvertBigFileToGib(filePath); + BigFileVariantPath.PrepareBigFileResumePath(filePath); + + File.ReadAllText(filePath).Should().Be("readme"); + Directory.EnumerateFileSystemEntries(testDirectory.Path).Should().ContainSingle() + .Which.Should().Be(filePath); + } + + /// + /// The .gib rename belongs to packages alone. A same-named installed package beside a non-package + /// download must not be dragged into a resume it has nothing to do with. + /// + [Fact] + public void PrepareBigFileResumePath_NonPackageFile_LeavesTheInstalledPackageAlone() + { + using TestDirectory testDirectory = new(); + string textPath = Path.Combine(testDirectory.Path, "readme.txt"); + string gibPath = Path.Combine(testDirectory.Path, "readme.gib"); + File.WriteAllText(gibPath, "installed"); + + BigFileVariantPath.PrepareBigFileResumePath(textPath); + + File.ReadAllText(gibPath).Should().Be("installed"); + File.Exists(textPath).Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Common/FileSystemPathSafetyTests.cs b/GenLauncherGO.Tests/Infrastructure/Common/FileSystemPathSafetyTests.cs new file mode 100644 index 00000000..fb30b42f --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Common/FileSystemPathSafetyTests.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Tests.Infrastructure.Common; + +public sealed class FileSystemPathSafetyTests +{ + // Callers name the paths they guard and the checks build the sentence, so these assertions pin the generated + // wording rather than a message the test supplied. + private const string PathSubject = "Test paths"; + + private const string OwnerDescription = "the owned root"; + + private const string LinkedMessage = "Test paths must not contain reparse points."; + + [Fact] + public void ResolveOwnedSubpath_ReturnsNormalizedChildPath() + { + using TestDirectory directory = new(); + string candidatePath = Path.Combine(directory.Path, "Child", "..", "Child", "file.txt"); + + string result = FileSystemPathSafety.ResolveOwnedSubpath( + directory.Path, + candidatePath, + PathSubject, + OwnerDescription); + + result.Should().Be(Path.GetFullPath(Path.Combine(directory.Path, "Child", "file.txt"))); + } + + [Fact] + public void ResolveOwnedSubpath_RejectsPathOutsideOwnedRoot() + { + using TestDirectory directory = new(); + string outsidePath = Path.Combine(directory.Path, "..", "outside.txt"); + + Action act = () => FileSystemPathSafety.ResolveOwnedSubpath( + directory.Path, + outsidePath, + PathSubject, + OwnerDescription); + + act.Should().Throw() + .WithMessage("Test paths must stay inside the owned root."); + } + + [Fact] + public void ResolveOwnedSubpath_LinkedOwnedRoot_RejectsPath() + { + using TestDirectory directory = new(); + string linkedRoot = Path.Combine(directory.Path, "LinkedRoot"); + string targetPath = Path.Combine(directory.Path, "Target"); + ReparsePointTestSupport.CreateDirectoryJunction(linkedRoot, targetPath); + + Action act = () => FileSystemPathSafety.ResolveOwnedSubpath( + linkedRoot, + Path.Combine(linkedRoot, "file.txt"), + PathSubject, + OwnerDescription); + + act.Should().Throw() + .WithMessage(LinkedMessage); + + Directory.Delete(linkedRoot, false); + } + + [Fact] + public void ResolveOwnedSubpath_RejectsLinkedCandidateAncestor() + { + using TestDirectory directory = new(); + string targetPath = Path.Combine(directory.Path, "Target"); + string linkPath = Path.Combine(directory.Path, "Linked"); + ReparsePointTestSupport.CreateDirectoryJunction(linkPath, targetPath); + + Action act = () => FileSystemPathSafety.ResolveOwnedSubpath( + directory.Path, + Path.Combine(linkPath, "file.txt"), + PathSubject, + OwnerDescription); + + act.Should().Throw() + .WithMessage(LinkedMessage); + FileSystemPathSafety.IsReparsePoint(linkPath).Should().BeTrue(); + + Directory.Delete(linkPath, false); + } + + [Fact] + public void ExistingPathChainContainsReparsePoint_ReturnsFalseForRootAndMissingChild() + { + using TestDirectory directory = new(); + string missingChild = Path.Combine(directory.Path, "Missing", "file.txt"); + + FileSystemPathSafety.ExistingPathChainContainsReparsePoint( + Path.GetPathRoot(directory.Path)!, + PathSubject).Should().BeFalse(); + FileSystemPathSafety.ExistingPathChainContainsReparsePoint( + missingChild, + PathSubject).Should().BeFalse(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DirectoryTreeApis_RootReparsePoint_RejectTree(bool enumerateFiles) + { + using TestDirectory directory = new(); + string linkedRoot = Path.Combine(directory.Path, "LinkedRoot"); + string targetPath = Path.Combine(directory.Path, "Target"); + ReparsePointTestSupport.CreateDirectoryJunction(linkedRoot, targetPath); + + Action act = enumerateFiles + ? () => _ = FileSystemPathSafety.GetDirectoryFilesWithNoReparsePoints(linkedRoot, PathSubject) + : () => FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints(linkedRoot, PathSubject); + + act.Should().Throw() + .WithMessage(LinkedMessage); + } + + [Fact] + public void EnsureDirectoryTreeHasNoReparsePoints_ChildReparsePoint_RejectsPath() + { + using TestDirectory directory = new(); + string rootPath = Path.Combine(directory.Path, "Root"); + string linkedTarget = Path.Combine(directory.Path, "Target"); + string linkPath = Path.Combine(rootPath, "Linked"); + Directory.CreateDirectory(rootPath); + Directory.CreateDirectory(linkedTarget); + ReparsePointTestSupport.CreateDirectoryJunction(linkPath, linkedTarget); + + Action act = () => FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints(rootPath, PathSubject); + + act.Should().Throw() + .WithMessage(LinkedMessage); + } + + [Fact] + public void EnsureDirectoryTreeHasNoReparsePoints_NestedReparsePoint_RejectsPath() + { + using TestDirectory directory = new(); + string rootPath = Path.Combine(directory.Path, "Root"); + string nestedPath = Path.Combine(rootPath, "Nested"); + string targetPath = Path.Combine(directory.Path, "Target"); + string linkPath = Path.Combine(nestedPath, "Linked"); + Directory.CreateDirectory(nestedPath); + ReparsePointTestSupport.CreateDirectoryJunction(linkPath, targetPath); + + Action act = () => FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints(rootPath, PathSubject); + + act.Should().Throw() + .WithMessage(LinkedMessage); + } + + [Fact] + public void GetDirectoryFilesWithNoReparsePoints_ReturnsNestedFiles() + { + using TestDirectory directory = new(); + string firstFilePath = Path.Combine(directory.Path, "first.txt"); + string secondFilePath = Path.Combine(directory.Path, "Nested", "second.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(secondFilePath)!); + File.WriteAllText(firstFilePath, "first"); + File.WriteAllText(secondFilePath, "second"); + + IReadOnlyList result = FileSystemPathSafety.GetDirectoryFilesWithNoReparsePoints( + directory.Path, + PathSubject); + + result.Should().BeEquivalentTo(firstFilePath, secondFilePath); + } + + [Fact] + public void GetDirectoryFilesWithNoReparsePoints_ChildReparsePoint_RejectsTree() + { + using TestDirectory directory = new(); + string rootPath = Path.Combine(directory.Path, "Root"); + string linkedTarget = Path.Combine(directory.Path, "Target"); + Directory.CreateDirectory(rootPath); + ReparsePointTestSupport.CreateDirectoryJunction(Path.Combine(rootPath, "Linked"), linkedTarget); + + Action act = () => FileSystemPathSafety.GetDirectoryFilesWithNoReparsePoints(rootPath, PathSubject); + + act.Should().Throw() + .WithMessage(LinkedMessage); + } + + [Fact] + public void CreateRecursiveNoLinksOptions_SkipsReparsePoints() + { + EnumerationOptions result = FileSystemPathSafety.CreateRecursiveNoLinksOptions(); + + result.AttributesToSkip.Should().Be(FileAttributes.ReparsePoint); + result.IgnoreInaccessible.Should().BeFalse(); + result.RecurseSubdirectories.Should().BeTrue(); + result.ReturnSpecialDirectories.Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Common/ManifestPathResolverTests.cs b/GenLauncherGO.Tests/Infrastructure/Common/ManifestPathResolverTests.cs new file mode 100644 index 00000000..7c6bf035 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Common/ManifestPathResolverTests.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Tests.Infrastructure.Common; + +public sealed class ManifestPathResolverTests +{ + [Theory] + [InlineData("Data/INI/GameData.ini")] + [InlineData(@"Data\INI\GameData.ini")] + [InlineData(" Data/INI/GameData.ini ")] + public void ResolvePath_ReturnsFullPathUnderRoot(string manifestFileName) + { + using TestDirectory directory = new(); + + string result = ManifestPathResolver.ResolvePath(directory.Path, manifestFileName); + + result.Should().Be(Path.GetFullPath(Path.Combine(directory.Path, "Data", "INI", "GameData.ini"))); + } + + /// + /// Manifest file names arrive from a remote catalog, and a rooted one resolves against the volume rather than + /// the package folder. A leading separator roots a path just as a drive letter does, so both spellings have to + /// be refused. + /// + [Theory] + [InlineData(@"C:\Package\Data.big")] + [InlineData("C:Package/Data.big")] + [InlineData("/Package/Data.big")] + [InlineData(@"\Package\Data.big")] + [InlineData("../Data.big")] + [InlineData("./Data.big")] + public void NormalizeRelativePath_RejectsUnsafePaths(string manifestFileName) + { + Action act = () => ManifestPathResolver.NormalizeRelativePath(manifestFileName); + + act.Should().Throw(); + } + + [Fact] + public void NormalizeForManifestIndex_UsesSlashSeparators() + { + string result = ManifestPathResolver.NormalizeForManifestIndex(@"Data\INI\GameData.ini"); + + result.Should().Be("Data/INI/GameData.ini"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Common/OwnedDirectoryTreeTests.cs b/GenLauncherGO.Tests/Infrastructure/Common/OwnedDirectoryTreeTests.cs new file mode 100644 index 00000000..a52353b3 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Common/OwnedDirectoryTreeTests.cs @@ -0,0 +1,624 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Tests.Infrastructure.Common; + +public sealed class OwnedDirectoryTreeTests +{ + [Fact] + public void EnsureExists_MissingDirectory_CreatesDirectory() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = Path.Combine(ownedRoot, "Content"); + + string result = OwnedDirectoryTree.EnsureExists(ownedRoot, contentPath); + + result.Should().Be(Path.GetFullPath(contentPath)); + Directory.Exists(contentPath).Should().BeTrue(); + } + + [Fact] + public void EnsureExists_FileAtDirectoryPath_Throws() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateFile("Owned/Content", "occupied"); + + Action act = () => OwnedDirectoryTree.EnsureExists(ownedRoot, contentPath); + + act.Should().Throw(); + File.ReadAllText(contentPath).Should().Be("occupied"); + } + + [Fact] + public void EnsureExists_LinkedLeaf_RejectsWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkPath = Path.Combine(ownedRoot, "Version"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + + Action act = () => OwnedDirectoryTree.EnsureExists(ownedRoot, linkPath); + + act.Should().Throw(); + FileSystemPathSafety.IsReparsePoint(linkPath).Should().BeTrue(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void EnsureRealDirectory_LinkedLeaf_ReplacesLinkWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkPath = Path.Combine(ownedRoot, "Version"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + + string result = OwnedDirectoryTree.EnsureRealDirectory(ownedRoot, linkPath); + + result.Should().Be(Path.GetFullPath(linkPath)); + Directory.Exists(linkPath).Should().BeTrue(); + FileSystemPathSafety.IsReparsePoint(linkPath).Should().BeFalse(); + Directory.EnumerateFileSystemEntries(linkPath).Should().BeEmpty(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DirectoryPreparation_FileAtLeaf_RejectsWithoutDeletingFile(bool prepareEmpty) + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateFile("Owned/Content", "occupied"); + + Action act = prepareEmpty + ? () => OwnedDirectoryTree.PrepareEmpty(ownedRoot, contentPath) + : () => OwnedDirectoryTree.EnsureRealDirectory(ownedRoot, contentPath); + + act.Should().Throw(); + File.ReadAllText(contentPath).Should().Be("occupied"); + } + + [Fact] + public void DeleteIfExists_NestedDirectoryLink_DeletesTreeWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateDirectory("Owned/Content"); + string linkPath = Path.Combine(contentPath, "Linked"); + directory.CreateFile("Owned/Content/owned.txt", "owned"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + + bool deleted = OwnedDirectoryTree.DeleteIfExists( + new OwnedContentPath(ownedRoot, contentPath)); + + deleted.Should().BeTrue(); + Directory.Exists(contentPath).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void DeleteIfExists_LinkedLeaf_DeletesWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkPath = Path.Combine(ownedRoot, "Version"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + + bool deleted = OwnedDirectoryTree.DeleteIfExists( + new OwnedContentPath(ownedRoot, linkPath)); + + deleted.Should().BeTrue(); + Directory.Exists(linkPath).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void DeleteIfExists_LinkedAncestor_RejectsWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkedAncestor = Path.Combine(ownedRoot, "Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + linkedAncestor, + canaryFileName: "Child/target.txt"); + string candidatePath = Path.Combine(linkedAncestor, "Child"); + + Action act = () => OwnedDirectoryTree.DeleteIfExists( + new OwnedContentPath(ownedRoot, candidatePath)); + + act.Should().Throw(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void DeleteIfExists_MissingDirectory_ReturnsFalse() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string missingPath = Path.Combine(ownedRoot, "Missing"); + + bool deleted = OwnedDirectoryTree.DeleteIfExists(ownedRoot, missingPath); + + deleted.Should().BeFalse(); + Directory.Exists(ownedRoot).Should().BeTrue(); + } + + [Fact] + public void PrepareEmpty_PopulatedDirectory_DeletesChildrenWithoutFollowingLinks() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateDirectory("Owned/Content"); + string linkPath = Path.Combine(contentPath, "Linked"); + directory.CreateFile("Owned/Content/file.txt", "file"); + directory.CreateFile("Owned/Content/Nested/file.txt", "nested"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + + string result = OwnedDirectoryTree.PrepareEmpty(ownedRoot, contentPath); + + result.Should().Be(Path.GetFullPath(contentPath)); + Directory.Exists(contentPath).Should().BeTrue(); + Directory.EnumerateFileSystemEntries(contentPath).Should().BeEmpty(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void PrepareEmpty_LinkedLeaf_ReplacesLinkWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkPath = Path.Combine(ownedRoot, "Scratch"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + + string result = OwnedDirectoryTree.PrepareEmpty(ownedRoot, linkPath); + + result.Should().Be(Path.GetFullPath(linkPath)); + FileSystemPathSafety.IsReparsePoint(linkPath).Should().BeFalse(); + Directory.EnumerateFileSystemEntries(linkPath).Should().BeEmpty(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void PrepareEmptyExcept_SelectedChild_PreservesOnlySelectedChild() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateDirectory("Owned/Content"); + string preservedPath = directory.CreateDirectory("Owned/Content/Preserved"); + string preservedFilePath = directory.CreateFile("Owned/Content/Preserved/state.json", "state"); + string removedPath = directory.CreateDirectory("Owned/Content/Removed"); + directory.CreateFile("Owned/Content/Removed/file.txt", "removed"); + directory.CreateFile("Owned/Content/transient.txt", "transient"); + + string result = OwnedDirectoryTree.PrepareEmptyExcept(ownedRoot, contentPath, preservedPath); + + result.Should().Be(Path.GetFullPath(contentPath)); + File.ReadAllText(preservedFilePath).Should().Be("state"); + Directory.Exists(removedPath).Should().BeFalse(); + Directory.EnumerateFileSystemEntries(contentPath).Should().ContainSingle() + .Which.Should().Be(preservedPath); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PrepareEmptyExcept_MissingOrLinkedRoot_PreparesRealEmptyDirectory(bool linkedRoot) + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = Path.Combine(ownedRoot, "Content"); + string? canaryFilePath = null; + string? canaryContents = null; + if (linkedRoot) + { + ProtectedJunction junction = + ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, contentPath); + canaryFilePath = junction.CanaryFilePath; + canaryContents = junction.CanaryContents; + } + + string result = OwnedDirectoryTree.PrepareEmptyExcept( + ownedRoot, + contentPath, + Path.Combine(contentPath, "Preserved")); + + result.Should().Be(Path.GetFullPath(contentPath)); + FileSystemPathSafety.IsReparsePoint(contentPath).Should().BeFalse(); + Directory.EnumerateFileSystemEntries(contentPath).Should().BeEmpty(); + if (canaryFilePath is not null) + { + File.ReadAllText(canaryFilePath).Should().Be(canaryContents); + } + } + + [Fact] + public void PrepareEmptyExcept_FileAtRoot_RejectsWithoutDeletingFile() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateFile("Owned/Content", "occupied"); + + Action act = () => OwnedDirectoryTree.PrepareEmptyExcept( + ownedRoot, + contentPath, + Path.Combine(contentPath, "Preserved")); + + act.Should().Throw(); + File.ReadAllText(contentPath).Should().Be("occupied"); + } + + [Fact] + public void DeleteEmptyParents_EmptyAncestorChain_DeletesThroughOwnedBoundary() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string parentPath = directory.CreateDirectory("Owned/A/B"); + string childPath = Path.Combine(parentPath, "missing.file"); + + IReadOnlyList deletedPaths = OwnedDirectoryTree.DeleteEmptyParents(ownedRoot, childPath); + + deletedPaths.Should().Equal( + Path.Combine(ownedRoot, "A", "B"), + Path.Combine(ownedRoot, "A")); + Directory.Exists(ownedRoot).Should().BeTrue(); + } + + [Fact] + public void DeleteEmptyParents_NonEmptyAncestor_StopsAfterEmptyChildParent() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string parentPath = directory.CreateDirectory("Owned/A/B"); + string childPath = Path.Combine(parentPath, "missing.file"); + string siblingFilePath = directory.CreateFile("Owned/A/keep.txt", "keep"); + + IReadOnlyList deletedPaths = OwnedDirectoryTree.DeleteEmptyParents(ownedRoot, childPath); + + deletedPaths.Should().Equal(parentPath); + Directory.Exists(Path.Combine(ownedRoot, "A")).Should().BeTrue(); + File.ReadAllText(siblingFilePath).Should().Be("keep"); + } + + [Fact] + public void DeleteEmptyParents_ChildOutsideOwnedRoot_Throws() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string siblingPath = directory.CreateDirectory("Sibling/A"); + string childPath = Path.Combine(siblingPath, "missing.file"); + + Action act = () => OwnedDirectoryTree.DeleteEmptyParents(ownedRoot, childPath); + + act.Should().Throw(); + Directory.Exists(siblingPath).Should().BeTrue(); + } + + [Fact] + public void DeleteEmptyParents_LinkedParent_RejectsWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkPath = Path.Combine(ownedRoot, "Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + string childPath = Path.Combine(linkPath, "missing.file"); + + Action act = () => OwnedDirectoryTree.DeleteEmptyParents(ownedRoot, childPath); + + act.Should().Throw(); + FileSystemPathSafety.IsReparsePoint(linkPath).Should().BeTrue(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void DeleteEmptyParents_MissingIntermediate_ContinuesToExistingAncestor() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string existingAncestor = directory.CreateDirectory("Owned/A"); + string childPath = Path.Combine(existingAncestor, "Missing", "Nested", "missing.file"); + + IReadOnlyList deletedPaths = OwnedDirectoryTree.DeleteEmptyParents(ownedRoot, childPath); + + deletedPaths.Should().Equal(existingAncestor); + Directory.Exists(existingAncestor).Should().BeFalse(); + Directory.Exists(ownedRoot).Should().BeTrue(); + } + + [Fact] + public void DeleteEmptyParentsIncludingRoot_EmptyAncestorChain_DeletesExclusiveOwnedRoot() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string parentPath = directory.CreateDirectory("Owned/A/B"); + string childPath = Path.Combine(parentPath, "missing.file"); + + IReadOnlyList deletedPaths = OwnedDirectoryTree.DeleteEmptyParentsIncludingRoot( + ownedRoot, + childPath); + + deletedPaths.Should().Equal( + Path.Combine(ownedRoot, "A", "B"), + Path.Combine(ownedRoot, "A"), + ownedRoot); + Directory.Exists(ownedRoot).Should().BeFalse(); + } + + [Fact] + public void DeleteEmptyParentsIncludingRoot_NonEmptyRoot_PreservesRoot() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string parentPath = directory.CreateDirectory("Owned/A/B"); + string childPath = Path.Combine(parentPath, "missing.file"); + string retainedFilePath = directory.CreateFile("Owned/retained.txt", "retained"); + + IReadOnlyList deletedPaths = OwnedDirectoryTree.DeleteEmptyParentsIncludingRoot( + ownedRoot, + childPath); + + deletedPaths.Should().Equal( + Path.Combine(ownedRoot, "A", "B"), + Path.Combine(ownedRoot, "A")); + File.ReadAllText(retainedFilePath).Should().Be("retained"); + } + + [Fact] + public void DeleteEmptyDirectories_MixedTree_DeletesOnlyEmptyDirectories() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateDirectory("Owned/Content"); + string emptyPath = directory.CreateDirectory("Owned/Content/Empty/Nested"); + string retainedFilePath = directory.CreateFile("Owned/Content/Retained/file.txt", "keep"); + string linkPath = Path.Combine(contentPath, "Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + string targetEmptyPath = directory.CreateDirectory("ExternalTarget/Empty"); + + bool rootDeleted = OwnedDirectoryTree.DeleteEmptyDirectories( + new OwnedContentPath(ownedRoot, contentPath)); + + rootDeleted.Should().BeFalse(); + Directory.Exists(emptyPath).Should().BeFalse(); + File.ReadAllText(retainedFilePath).Should().Be("keep"); + FileSystemPathSafety.IsReparsePoint(linkPath).Should().BeTrue(); + Directory.Exists(contentPath).Should().BeTrue(); + Directory.Exists(targetEmptyPath).Should().BeTrue(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void DeleteEmptyDirectories_EntireTreeIsEmpty_DeletesRoot() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateDirectory("Owned/Content/Empty/Nested"); + string treeRoot = Path.Combine(ownedRoot, "Content"); + + bool rootDeleted = OwnedDirectoryTree.DeleteEmptyDirectories( + new OwnedContentPath(ownedRoot, treeRoot)); + + rootDeleted.Should().BeTrue(); + Directory.Exists(contentPath).Should().BeFalse(); + Directory.Exists(treeRoot).Should().BeFalse(); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public void DeleteEmptyDirectories_MissingFileOrLinkedRoot_ReturnsFalseWithoutDeletingTarget( + bool pathExists, + bool linkedRoot) + { + using TestDirectory directory = new(); + (string ownedRoot, string treeRoot, string? protectedFilePath, string? protectedContents) = + CreateInvalidTreeRoot(directory, pathExists, linkedRoot); + + bool deleted = OwnedDirectoryTree.DeleteEmptyDirectories( + new OwnedContentPath(ownedRoot, treeRoot)); + + deleted.Should().BeFalse(); + if (protectedFilePath is not null) + { + File.ReadAllText(protectedFilePath).Should().Be(protectedContents); + } + } + + [Fact] + public void DeleteReparsePoints_NestedLink_DeletesLinkWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateDirectory("Owned/Content/Nested"); + string treeRoot = Path.Combine(ownedRoot, "Content"); + string linkPath = Path.Combine(contentPath, "Linked"); + string retainedFilePath = directory.CreateFile("Owned/Content/Nested/retained.txt", "retained"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + + IReadOnlyList deletedPaths = OwnedDirectoryTree.DeleteReparsePoints( + new OwnedContentPath(ownedRoot, treeRoot)); + + deletedPaths.Should().Equal(linkPath); + Directory.Exists(linkPath).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + File.ReadAllText(retainedFilePath).Should().Be("retained"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public void DeleteReparsePoints_MissingFileOrLinkedRoot_RejectsWithoutDeletingTarget( + bool pathExists, + bool linkedRoot) + { + using TestDirectory directory = new(); + (string ownedRoot, string treeRoot, string? protectedFilePath, string? protectedContents) = + CreateInvalidTreeRoot(directory, pathExists, linkedRoot); + + Action act = () => OwnedDirectoryTree.DeleteReparsePoints( + new OwnedContentPath(ownedRoot, treeRoot)); + + act.Should().Throw(); + if (protectedFilePath is not null) + { + File.ReadAllText(protectedFilePath).Should().Be(protectedContents); + } + } + + /// + /// A missing leaf is created, so a linked ancestor has to be refused before that: creating the leaf would + /// place a launcher directory inside whatever the link resolves to. + /// + [Fact] + public void EnsureRealDirectory_LinkedParent_RejectsWithoutCreatingThroughTheLink() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkPath = Path.Combine(ownedRoot, "Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + string contentPath = Path.Combine(linkPath, "Content"); + + Action act = () => OwnedDirectoryTree.EnsureRealDirectory(ownedRoot, contentPath); + + act.Should().Throw(); + Directory.Exists(Path.Combine(junction.TargetDirectory, "Content")).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void PrepareEmpty_MissingDirectoryUnderLinkedParent_RejectsWithoutCreatingIt() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkPath = Path.Combine(ownedRoot, "Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + string contentPath = Path.Combine(linkPath, "Content"); + + Action act = () => OwnedDirectoryTree.PrepareEmpty(ownedRoot, contentPath); + + act.Should().Throw(); + Directory.Exists(Path.Combine(junction.TargetDirectory, "Content")).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// A file reached through a link is deleted as a plain file, so the ancestor check is the only thing between + /// the delete and a file the launcher does not own. + /// + [Fact] + public void DeleteIfExists_FileUnderLinkedAncestor_RejectsWithoutDeletingIt() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkPath = Path.Combine(ownedRoot, "Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + string candidatePath = Path.Combine(linkPath, "target.txt"); + + Action act = () => OwnedDirectoryTree.DeleteIfExists(ownedRoot, candidatePath); + + act.Should().Throw(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// Pruning walks upward and deletes as it goes, so an owned root that is itself a link would hand the walk a + /// directory tree that belongs to somebody else. + /// + [Fact] + public void DeleteEmptyParents_LinkedOwnedRoot_RejectsWithoutDeletingThroughTheLink() + { + using TestDirectory directory = new(); + string linkedRoot = directory.GetPath("Owned"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkedRoot); + string emptyParentPath = directory.CreateDirectory("ExternalTarget/A"); + string childPath = Path.Combine(linkedRoot, "A", "missing.file"); + + Action act = () => OwnedDirectoryTree.DeleteEmptyParents(linkedRoot, childPath); + + act.Should().Throw(); + Directory.Exists(emptyParentPath).Should().BeTrue(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// Pruning is cleanup that runs after a failure, so an unexpected file where a directory was recorded has to + /// stop the walk rather than fail the cleanup. + /// + [Fact] + public void DeleteEmptyParents_FileInAncestorChain_StopsWithoutDeletingIt() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string filePath = directory.CreateFile("Owned/A", "occupied"); + string childPath = Path.Combine(filePath, "missing.file"); + + IReadOnlyList deletedPaths = OwnedDirectoryTree.DeleteEmptyParents(ownedRoot, childPath); + + deletedPaths.Should().BeEmpty(); + File.ReadAllText(filePath).Should().Be("occupied"); + } + + [Fact] + public void DeleteEmptyParentsIncludingRoot_FileAtOwnedRoot_LeavesItUntouched() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateFile("Owned", "occupied"); + string childPath = Path.Combine(ownedRoot, "missing.file"); + + IReadOnlyList deletedPaths = OwnedDirectoryTree.DeleteEmptyParentsIncludingRoot( + ownedRoot, + childPath); + + deletedPaths.Should().BeEmpty(); + File.ReadAllText(ownedRoot).Should().Be("occupied"); + } + + /// + /// This overload is handed a launcher-owned root such as the package backup folder. A link in its place has to + /// be reported, because answering "nothing was empty" would let the caller believe the folder was inspected. + /// + [Fact] + public void DeleteEmptyDirectories_LinkedOwnedRoot_RejectsWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string linkedRoot = directory.GetPath("Owned"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkedRoot); + string emptyChildPath = directory.CreateDirectory("ExternalTarget/Empty"); + + Action act = () => OwnedDirectoryTree.DeleteEmptyDirectories(linkedRoot); + + act.Should().Throw(); + Directory.Exists(emptyChildPath).Should().BeTrue(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + private static (string OwnedRoot, string TreeRoot, string? ProtectedFilePath, string? ProtectedContents) + CreateInvalidTreeRoot( + TestDirectory directory, + bool pathExists, + bool linkedRoot) + { + string ownedRoot = directory.CreateDirectory("Owned"); + string treeRoot = Path.Combine(ownedRoot, "Content"); + if (!pathExists) + { + return (ownedRoot, treeRoot, null, null); + } + + if (!linkedRoot) + { + directory.CreateFile("Owned/Content", "occupied"); + return (ownedRoot, treeRoot, treeRoot, "occupied"); + } + + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, treeRoot); + return (ownedRoot, treeRoot, junction.CanaryFilePath, junction.CanaryContents); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Common/PhysicalDirectoryPathTests.cs b/GenLauncherGO.Tests/Infrastructure/Common/PhysicalDirectoryPathTests.cs new file mode 100644 index 00000000..b82d2bc9 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Common/PhysicalDirectoryPathTests.cs @@ -0,0 +1,212 @@ +using System; +using System.IO; +using System.Linq; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Tests.Infrastructure.Common; + +/// +/// The launcher decides whether two paths name the same game folder, and whether a recorded deployment still +/// describes the folder in front of it, from these two answers. Both have to see through the aliases a path can be +/// spelled with instead of comparing the spelling itself. +/// +public sealed class PhysicalDirectoryPathTests +{ + [Fact] + public void ResolveExisting_JunctionPath_ReturnsTheCanonicalTargetPath() + { + using TestDirectory directory = new(); + string targetDirectory = directory.CreateDirectory("Target"); + string junctionPath = directory.GetPath("Link"); + ReparsePointTestSupport.CreateDirectoryJunction(junctionPath, targetDirectory); + + string result = PhysicalDirectoryPath.ResolveExisting(junctionPath); + + result.Should().Be(PhysicalDirectoryPath.ResolveExisting(targetDirectory)); + Path.GetFileName(result).Should().Be("Target"); + } + + /// + /// The canonical path is compared against ordinary normalized paths and stored in the deployment manifest, so a + /// device prefix or a trailing separator would silently break every one of those comparisons. + /// + [Fact] + public void ResolveExisting_ReturnsAPlainPathWithoutADevicePrefixOrTrailingSeparator() + { + using TestDirectory directory = new(); + string targetDirectory = directory.CreateDirectory("Target"); + + string result = PhysicalDirectoryPath.ResolveExisting(targetDirectory); + + result.Should().NotStartWith(@"\\?\"); + Path.EndsInDirectorySeparator(result).Should().BeFalse(); + Directory.Exists(result).Should().BeTrue(); + } + + /// + /// A caller may already hold the extended-length spelling of a path, which must not be prefixed a second time. + /// + [Fact] + public void ResolveExisting_ExtendedLengthSpelling_ReturnsTheSameCanonicalPath() + { + using TestDirectory directory = new(); + string targetDirectory = directory.CreateDirectory("Target"); + + string result = PhysicalDirectoryPath.ResolveExisting(@"\\?\" + Path.GetFullPath(targetDirectory)); + + result.Should().Be(PhysicalDirectoryPath.ResolveExisting(targetDirectory)); + } + + /// + /// Game and launcher folders nest deeply enough to outgrow the first buffer the resolver asks Windows to fill, + /// and a truncated canonical path would name a different directory than the one that was opened. + /// + [Fact] + public void ResolveExisting_PathLongerThanTheInitialBuffer_ReturnsTheCompletePath() + { + using TestDirectory directory = new(); + string[] segments = [.. Enumerable.Repeat(new string('d', 80), 7)]; + string deepDirectory = directory.CreateDirectory(string.Join('/', segments)); + + string result = PhysicalDirectoryPath.ResolveExisting(deepDirectory); + + result.Should().Be(Path.Combine( + PhysicalDirectoryPath.ResolveExisting(directory.Path), + Path.Combine(segments))); + result.Length.Should().BeGreaterThan(512); + } + + [Fact] + public void ResolveExisting_MissingDirectory_Throws() + { + using TestDirectory directory = new(); + string missingPath = directory.GetPath("Missing"); + + Action act = () => PhysicalDirectoryPath.ResolveExisting(missingPath); + + act.Should().Throw(); + } + + [Fact] + public void ResolveExisting_FilePath_Throws() + { + using TestDirectory directory = new(); + string filePath = directory.CreateFile("game.exe", "binary"); + + Action act = () => PhysicalDirectoryPath.ResolveExisting(filePath); + + act.Should().Throw(); + } + + /// + /// Identity is what tells the launcher a recorded game folder is still the same folder after it was reached by + /// another name, so an alias has to answer with the identity of what it resolves to. + /// + [Fact] + public void GetIdentity_JunctionPath_ReturnsTheTargetIdentity() + { + using TestDirectory directory = new(); + string targetDirectory = directory.CreateDirectory("Target"); + string junctionPath = directory.GetPath("Link"); + ReparsePointTestSupport.CreateDirectoryJunction(junctionPath, targetDirectory); + + PhysicalFileSystemIdentity result = PhysicalDirectoryPath.GetIdentity(junctionPath); + + result.Should().Be(PhysicalDirectoryPath.GetIdentity(targetDirectory)); + } + + /// + /// Two folders on one volume share a volume serial number and must still be told apart, which is the whole + /// reason the file index is part of the identity. + /// + [Fact] + public void GetIdentity_DifferentDirectories_ReturnsDifferentIdentities() + { + using TestDirectory directory = new(); + string firstDirectory = directory.CreateDirectory("First"); + string secondDirectory = directory.CreateDirectory("Second"); + + PhysicalFileSystemIdentity result = PhysicalDirectoryPath.GetIdentity(firstDirectory); + + PhysicalFileSystemIdentity secondIdentity = PhysicalDirectoryPath.GetIdentity(secondDirectory); + result.Should().NotBe(secondIdentity); + result.VolumeSerialNumber.Should().Be(secondIdentity.VolumeSerialNumber); + } + + [Fact] + public void GetIdentity_MissingDirectory_Throws() + { + using TestDirectory directory = new(); + string missingPath = directory.GetPath("Missing"); + + Action act = () => PhysicalDirectoryPath.GetIdentity(missingPath); + + act.Should().Throw(); + } + + [Fact] + public void GetFileIdentity_JunctionPath_ReturnsTheTargetFileIdentity() + { + using TestDirectory directory = new(); + string junctionPath = directory.GetPath("Link"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + junctionPath); + + PhysicalFileSystemIdentity result = PhysicalDirectoryPath.GetFileIdentity( + Path.Combine(junctionPath, "target.txt")); + + result.Should().Be(PhysicalDirectoryPath.GetFileIdentity(junction.CanaryFilePath)); + } + + /// + /// Deployed files are identified while the game or another writer still holds them open, so reading an identity + /// must never contend for the file. + /// + [Fact] + public void GetFileIdentity_FileHeldOpen_ReturnsTheSameIdentity() + { + using TestDirectory directory = new(); + string filePath = directory.CreateFile("state.yaml", "state"); + PhysicalFileSystemIdentity closedIdentity = PhysicalDirectoryPath.GetFileIdentity(filePath); + using FileStream heldOpen = new(filePath, FileMode.Open, FileAccess.Write, FileShare.None); + + PhysicalFileSystemIdentity result = PhysicalDirectoryPath.GetFileIdentity(filePath); + + result.Should().Be(closedIdentity); + } + + [Fact] + public void GetFileIdentity_DifferentFiles_ReturnsDifferentIdentities() + { + using TestDirectory directory = new(); + string firstPath = directory.CreateFile("first.txt", "first"); + string secondPath = directory.CreateFile("second.txt", "second"); + + PhysicalFileSystemIdentity result = PhysicalDirectoryPath.GetFileIdentity(firstPath); + + result.Should().NotBe(PhysicalDirectoryPath.GetFileIdentity(secondPath)); + } + + [Fact] + public void GetFileIdentity_MissingFile_Throws() + { + using TestDirectory directory = new(); + string missingPath = directory.GetPath("missing.txt"); + + Action act = () => PhysicalDirectoryPath.GetFileIdentity(missingPath); + + act.Should().Throw(); + } + + [Fact] + public void GetFileIdentity_DirectoryPath_Throws() + { + using TestDirectory directory = new(); + string directoryPath = directory.CreateDirectory("Content"); + + Action act = () => PhysicalDirectoryPath.GetFileIdentity(directoryPath); + + act.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs new file mode 100644 index 00000000..acc3c9ef --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs @@ -0,0 +1,1036 @@ +using System; +using System.Collections.Generic; +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 GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Integrity.Services; +using GenLauncherGO.Infrastructure.Integrity.Support; +using GenLauncherGO.Infrastructure.Persistence.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Integrity.Services; + +public sealed class FileSystemContentIntegrityServiceTests +{ + [Fact] + public async Task VerifyAsync_ReportsVerificationErrorWhenSnapshotCannotBeReadAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + Directory.CreateDirectory(paths.IntegrityDirectory); + await File.WriteAllTextAsync(GetSnapshotPath(paths.IntegrityDirectory, "target"), "{", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.VerificationError && + issue.Action == IntegrityIssueAction.Block && + issue.RelativePath == "." && + !string.IsNullOrWhiteSpace(issue.Message)); + } + + /// + /// A snapshot the launcher cannot prove it owns is as untrustworthy as an unreadable one: a future schema may + /// have changed what the fields mean, and another target's document describes different content. + /// + [Theory] + [InlineData(2, "target")] + [InlineData(1, "other-target")] + public async Task VerifyAsync_SnapshotWithUnsupportedSchemaOrOwner_ReportsVerificationErrorAsync( + int schemaVersion, + string snapshotTargetId) + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + Directory.CreateDirectory(paths.IntegrityDirectory); + ContentIntegritySnapshotDocument snapshot = new( + schemaVersion, + snapshotTargetId, + ContentSourceKind.ManagedS3, + [], + []); + await File.WriteAllTextAsync(GetSnapshotPath(paths.IntegrityDirectory, "target"), JsonSerializer.Serialize(snapshot), TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.VerificationError && + issue.Action == IntegrityIssueAction.Block && + issue.RelativePath == "."); + } + + [Fact] + public async Task VerifyAsync_DetectsSameSizeSha256ModificationAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + string filePath = Path.Combine(content, "file.bin"); + await File.WriteAllTextAsync(filePath, "aaaa", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + await File.WriteAllTextAsync(filePath, "bbbb", TestContext.Current.CancellationToken); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.ModifiedFile && + issue.Action == IntegrityIssueAction.Repair && + issue.RelativePath == "file.bin" && + issue.ExpectedSizeBytes == 4); + } + + [Fact] + public async Task VerifyAsync_CollectsMissingUnexpectedAndEmptyDirectoryIssuesAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + string expectedPath = Path.Combine(content, "expected.txt"); + await File.WriteAllTextAsync(expectedPath, "expected", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + File.Delete(expectedPath); + await File.WriteAllTextAsync(Path.Combine(content, "unexpected.txt"), "unexpected", TestContext.Current.CancellationToken); + Directory.CreateDirectory(Path.Combine(content, "nested", "empty")); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + 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 VerifyAsyncAlways_ReportsManagedEmptyDirectoriesAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(Path.Combine(content, "nested", "empty")); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.EmptyDirectory && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "nested/empty"); + } + + [Fact] + public async Task VerifyAsync_ClassifiesManagedSingleFileDifferencesAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + string expectedPath = Path.Combine(content, "expected.txt"); + await File.WriteAllTextAsync(expectedPath, "expected", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedSingleFile); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + File.Delete(expectedPath); + await File.WriteAllTextAsync(Path.Combine(content, "unexpected.txt"), "unexpected", TestContext.Current.CancellationToken); + Directory.CreateDirectory(Path.Combine(content, "empty")); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.MissingFile && + issue.Action == IntegrityIssueAction.Redownload && + issue.RelativePath == "expected.txt"); + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.UnexpectedFile && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "unexpected.txt"); + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.EmptyDirectory && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "empty"); + } + + [Fact] + public async Task VerifyAsync_ClassifiesUnknownLegacyDifferencesForManualTrustAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + string filePath = Path.Combine(content, "file.txt"); + await File.WriteAllTextAsync(filePath, "before", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.UnknownLegacy); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + await File.WriteAllTextAsync(filePath, "after", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(content, "added.txt"), "added", TestContext.Current.CancellationToken); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().NotBeEmpty(); + report.Issues.Should().OnlyContain(issue => issue.Action == IntegrityIssueAction.TrustAsManual); + } + + [Fact] + public async Task VerifyAsync_MarksManualDifferencesForAbsorptionAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "before", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "after", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(content, "added.txt"), "added", TestContext.Current.CancellationToken); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().NotBeEmpty() + .And.OnlyContain(issue => issue.Action == IntegrityIssueAction.Absorb); + } + + /// + /// Manual content is where a user's own empty folders live, so a snapshot has to record them or every later + /// verification would ask to absorb the same folders again. + /// + [Fact] + public async Task VerifyAsync_ManualSnapshotWithEmptyDirectory_ReportsNoIssuesAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "content", TestContext.Current.CancellationToken); + Directory.CreateDirectory(Path.Combine(content, "empty")); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.HasIssues.Should().BeFalse(); + } + + [Fact] + public async Task CaptureSnapshotAsync_AbsorbsManualDifferencesAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + string filePath = Path.Combine(content, "file.txt"); + await File.WriteAllTextAsync(filePath, "before", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + await File.WriteAllTextAsync(filePath, "after", TestContext.Current.CancellationToken); + + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.HasIssues.Should().BeFalse(); + } + + [Fact] + public async Task CaptureSnapshotAsync_CommitsCompleteDocumentThroughAtomicWriterAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "content", TestContext.Current.CancellationToken); + RecordingAtomicFileWriter atomicFileWriter = new(); + FileSystemContentIntegrityService service = CreateService(atomicFileWriter); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + using var cancellationTokenSource = new CancellationTokenSource(); + + await service.CaptureSnapshotAsync(paths, target, cancellationTokenSource.Token); + + atomicFileWriter.WasWriteAsyncCalled.Should().BeTrue(); + atomicFileWriter.CancellationToken.Should().Be(cancellationTokenSource.Token); + atomicFileWriter.DestinationPath.Should().Be(GetSnapshotPath(paths.IntegrityDirectory, target.Id)); + ContentIntegritySnapshotDocument? snapshot = + JsonSerializer.Deserialize(atomicFileWriter.Contents!); + snapshot.Should().NotBeNull(); + snapshot!.TargetId.Should().Be(target.Id); + snapshot.Files.Should().ContainSingle().Which.RelativePath.Should().Be("file.txt"); + } + + /// + /// A later verification compares this document against a fresh scan, so the store orders its entries + /// itself rather than persisting whatever order the filesystem happened to enumerate. + /// + [Fact] + public async Task CaptureSnapshotAsync_OrdersEntriesIndependentlyOfEnumerationAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "zeta.txt"), "z", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(content, "alpha.txt"), "a", TestContext.Current.CancellationToken); + Directory.CreateDirectory(Path.Combine(content, "zulu")); + Directory.CreateDirectory(Path.Combine(content, "bravo")); + RecordingAtomicFileWriter atomicFileWriter = new(); + FileSystemContentIntegrityService service = CreateService(atomicFileWriter); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + + ContentIntegritySnapshotDocument snapshot = + JsonSerializer.Deserialize(atomicFileWriter.Contents!)!; + snapshot.Files.Select(file => file.RelativePath).Should().Equal("alpha.txt", "zeta.txt"); + snapshot.EmptyDirectories.Should().Equal("bravo", "zulu"); + } + + [Fact] + public async Task IntegritySnapshots_GameNamespaceChange_SwitchWithoutServiceRebuildAsync() + { + using TestDirectory directory = new(); + string executableDirectory = directory.CreateDirectory("Launcher"); + var storagePaths = new LauncherStoragePaths(executableDirectory); + LauncherPaths generalsPaths = storagePaths.CreateGamePaths( + SupportedGame.Generals, + directory.CreateDirectory("GeneralsGame")); + LauncherPaths zeroHourPaths = storagePaths.CreateGamePaths( + SupportedGame.ZeroHour, + directory.CreateDirectory("ZeroHourGame")); + var service = new FileSystemContentIntegrityService( + new AtomicFileWriter(), + NullLogger.Instance); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "content", TestContext.Current.CancellationToken); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + await service.CaptureSnapshotAsync(generalsPaths, target, CancellationToken.None); + ContentIntegrityReport zeroHourReport = await service.VerifyAsync( + zeroHourPaths, + new[] { target }, + CancellationToken.None); + + zeroHourReport.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.Untracked); + File.Exists(GetSnapshotPath(generalsPaths.IntegrityDirectory, target.Id)).Should().BeTrue(); + File.Exists(GetSnapshotPath(zeroHourPaths.IntegrityDirectory, target.Id)).Should().BeFalse(); + } + + [Fact] + public async Task VerifyAsync_PreservesIgnoredInactiveCacheFileAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "active.png"), "active", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(content, "inactive.png"), "inactive", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = new( + "target", + "Target", + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "inactive.png" }); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.HasIssues.Should().BeFalse(); + } + + [Fact] + public async Task CaptureSnapshotIf_MatchesExpectedFileSetAsyncCapturesExistingManagedCacheWithoutMutationAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + string activePath = Path.Combine(content, "active.png"); + string inactivePath = Path.Combine(content, "inactive.png"); + await File.WriteAllTextAsync(activePath, "active", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(inactivePath, "inactive", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = new( + "target", + "Target", + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "inactive.png" }); + + bool captured = await service.CaptureSnapshotIfMatchesExpectedFileSetAsync( + paths, + target, + new HashSet(StringComparer.OrdinalIgnoreCase) { "active.png" }, + CancellationToken.None); + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + captured.Should().BeTrue(); + report.HasIssues.Should().BeFalse(); + (await File.ReadAllTextAsync(activePath, TestContext.Current.CancellationToken)).Should().Be("active"); + (await File.ReadAllTextAsync(inactivePath, TestContext.Current.CancellationToken)).Should().Be("inactive"); + } + + [Fact] + public async Task CaptureSnapshotIf_MatchesExpectedFileSetAsyncRejectsExtrasWithoutSnapshottingAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "active.png"), "active", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(content, "extra.png"), "extra", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + bool captured = await service.CaptureSnapshotIfMatchesExpectedFileSetAsync( + paths, + target, + new HashSet(StringComparer.OrdinalIgnoreCase) { "active.png" }, + CancellationToken.None); + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + captured.Should().BeFalse(); + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == IntegrityIssueAction.Repair); + } + + /// + /// The expected file set describes files only, so a directory entry the manifest never mentioned still means the + /// content is not the package the launcher would be vouching for. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CaptureSnapshotIf_UnexpectedDirectoryEntry_LeavesTargetUntrackedAsync(bool linkedEntry) + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "active.png"), "active", TestContext.Current.CancellationToken); + string unexpectedEntryPath = Path.Combine(content, "unexpected"); + if (linkedEntry) + { + ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, unexpectedEntryPath); + } + else + { + Directory.CreateDirectory(unexpectedEntryPath); + } + + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + bool captured = await service.CaptureSnapshotIfMatchesExpectedFileSetAsync( + paths, + target, + new HashSet(StringComparer.OrdinalIgnoreCase) { "active.png" }, + CancellationToken.None); + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + captured.Should().BeFalse(); + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == IntegrityIssueAction.Repair); + } + + [Fact] + public async Task VerifyAsync_ReportsIgnoredUnsafeLinkWithoutFollowingItAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(content, "inactive.png")); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = new( + "target", + "Target", + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "inactive.png" }); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.UnsafeLink && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "inactive.png"); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Theory] + [InlineData(ContentSourceKind.ManagedS3, IntegrityIssueAction.Repair)] + [InlineData(ContentSourceKind.ManagedSingleFile, IntegrityIssueAction.Redownload)] + [InlineData(ContentSourceKind.Manual, IntegrityIssueAction.Absorb)] + [InlineData(ContentSourceKind.UnknownLegacy, IntegrityIssueAction.TrustAsManual)] + public async Task VerifyAsync_ClassifiesUntrackedContentBySourceAsync( + ContentSourceKind sourceKind, + IntegrityIssueAction expectedAction) + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, sourceKind); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == expectedAction); + } + + [Fact] + public async Task VerifyAsync_RequiresMigrationWhenSourceClassificationChangesAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "content", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget managedTarget = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(paths, managedTarget, CancellationToken.None); + ContentIntegrityTarget manualTarget = CreateTarget(content, ContentSourceKind.Manual); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { manualTarget }, + CancellationToken.None); + + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == IntegrityIssueAction.Absorb); + } + + [Fact] + public async Task ApplyCleanupAsync_DeletesConfirmedManagedExtrasAndEmptyDirectoriesAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + string nested = Path.Combine(content, "nested"); + string deeper = Path.Combine(nested, "deeper"); + Directory.CreateDirectory(deeper); + await File.WriteAllTextAsync(Path.Combine(deeper, "unexpected.txt"), "unexpected", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(target, "nested/deeper/unexpected.txt") + }); + + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + File.Exists(Path.Combine(deeper, "unexpected.txt")).Should().BeFalse(); + Directory.Exists(deeper).Should().BeFalse(); + Directory.Exists(nested).Should().BeFalse(); + } + + [Fact] + public async Task ApplyCleanupAsync_DeletesConfirmedDirectoryIssueAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + string unexpected = Path.Combine(content, "unexpected"); + Directory.CreateDirectory(unexpected); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(target, "unexpected", IntegrityIssueKind.EmptyDirectory) + }); + + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + Directory.Exists(unexpected).Should().BeFalse(); + } + + [Fact] + public async Task ApplyCleanupAsync_RejectsUnknownTargetAsync() + { + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + "missing", + "Missing", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "unexpected.txt") + }); + + Func cleanup = () => service.ApplyCleanupAsync( + report, + Array.Empty(), + CancellationToken.None); + + await cleanup.Should().ThrowAsync() + .WithMessage("The cleanup report references an unknown integrity target."); + } + + [Fact] + public async Task ApplyCleanupAsync_IgnoresMissingDeletedEntriesAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "missing.txt"), + CreateDeleteIssue(target, "missing/missing.txt") + }); + + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + Directory.Exists(content).Should().BeTrue(); + } + + [Fact] + public async Task ApplyCleanupAsync_SkipsEmptyDirectorySweepWhenRootIsMissingAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "missing-content"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(target, "missing.txt") + }); + + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + Directory.Exists(content).Should().BeFalse(); + } + + [Fact] + public async Task ApplyCleanupAsync_PreservesIgnoredAndNonEmptyDirectoriesAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + string unexpected = Path.Combine(content, "unexpected"); + string ignored = Path.Combine(content, "ignored"); + string nonEmpty = Path.Combine(content, "non-empty"); + Directory.CreateDirectory(unexpected); + Directory.CreateDirectory(ignored); + Directory.CreateDirectory(nonEmpty); + string unexpectedFile = Path.Combine(unexpected, "unexpected.txt"); + await File.WriteAllTextAsync(unexpectedFile, "unexpected", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(nonEmpty, "keep.txt"), "keep", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget( + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.Ordinal) { @"\IGNORED/" }); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(target, "unexpected/unexpected.txt") + }); + + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + File.Exists(unexpectedFile).Should().BeFalse(); + Directory.Exists(unexpected).Should().BeFalse(); + Directory.Exists(ignored).Should().BeTrue(); + Directory.Exists(nonEmpty).Should().BeTrue(); + } + + [Fact] + public async Task VerifyAsync_RejectsManualLinkWithoutFollowingItAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(content, "linked.txt")); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + 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(paths, target, CancellationToken.None); + await capture.Should().ThrowAsync(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public async Task VerifyAsync_ReportsLinkedTargetRootWithoutFollowingItAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.GetPath("content"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, content); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.UnsafeLink && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "."); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public async Task ApplyCleanupAsync_DeletesLinkedTargetRootWithoutDeletingTargetAsync() + { + using TestDirectory directory = new(); + string content = directory.GetPath("content"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, content); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(target, ".", IntegrityIssueKind.UnsafeLink) + }); + + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + Directory.Exists(content).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// A linked target root is the one arrangement where every issue path already crosses the link, so the cleanup + /// has to refuse the whole report instead of sweeping directories inside somebody else's folder. + /// + [Fact] + public async Task ApplyCleanupAsync_LinkedTargetRoot_RejectsWithoutTouchingTargetAsync() + { + using TestDirectory directory = new(); + string content = directory.GetPath("content"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, content); + string targetEmptyDirectory = directory.CreateDirectory("ExternalTarget/empty"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(target, "target.txt") + }); + + Func cleanup = () => service.ApplyCleanupAsync( + report, + new[] { target }, + CancellationToken.None); + + await cleanup.Should().ThrowAsync(); + Directory.Exists(content).Should().BeTrue(); + Directory.Exists(targetEmptyDirectory).Should().BeTrue(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public async Task ApplyCleanupAsync_RejectsLinkedAncestorWithoutDeletingTargetAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(content, "linked")); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(target, "linked/target.txt") + }); + + Func cleanup = () => service.ApplyCleanupAsync( + report, + new[] { target }, + CancellationToken.None); + + await cleanup.Should().ThrowAsync(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public async Task ApplyCleanupAsync_RejectsPathTraversalAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + string outsideFile = directory.CreateFile("outside.txt", "outside"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(target, "../outside.txt") + }); + + Func cleanup = () => service.ApplyCleanupAsync( + report, + new[] { target }, + CancellationToken.None); + + await cleanup.Should().ThrowAsync(); + (await File.ReadAllTextAsync(outsideFile, TestContext.Current.CancellationToken)).Should().Be("outside"); + } + + /// + /// A snapshot proves what the content was when it was captured, not that nothing has been linked into it + /// since, so tracked content is checked for unsafe links exactly like untracked content is. + /// + [Fact] + public async Task VerifyAsync_LinkAddedAfterSnapshot_ReportsUnsafeLinkAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "content", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(content, "linked")); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.UnsafeLink && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "linked"); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// A file the launcher cannot read is content it cannot vouch for, so verification blocks on it by name + /// instead of treating the unreadable entry as if it were simply missing. + /// + [Fact] + public async Task VerifyAsync_UnreadableFile_ReportsVerificationErrorAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + string lockedPath = directory.CreateFile("content/locked.bin", "locked"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + using FileStream exclusiveHandle = new(lockedPath, FileMode.Open, FileAccess.Read, FileShare.None); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.VerificationError && + issue.Action == IntegrityIssueAction.Block && + issue.RelativePath == "locked.bin"); + } + + [Fact] + public async Task ApplyCleanupAsync_CancelledToken_KeepsConfirmedEntryAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + string unexpectedPath = directory.CreateFile("content/unexpected.txt", "unexpected"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] { CreateDeleteIssue(target, "unexpected.txt") }); + using CancellationTokenSource cancellation = new(); + await cancellation.CancelAsync(); + + Func cleanup = () => service.ApplyCleanupAsync( + report, + new[] { target }, + cancellation.Token); + + await cleanup.Should().ThrowAsync(); + File.Exists(unexpectedPath).Should().BeTrue(); + } + + /// + /// The empty-directory sweep is the one part of cleanup that deletes entries no issue named, so it runs only + /// inside the targets the user actually confirmed deletions for. + /// + [Fact] + public async Task ApplyCleanupAsync_TargetWithoutDeleteIssues_KeepsEmptyDirectoriesAsync() + { + using TestDirectory directory = new(); + string cleanedContent = directory.CreateDirectory("cleaned"); + string untouchedContent = directory.CreateDirectory("untouched"); + directory.CreateFile("cleaned/nested/unexpected.txt", "unexpected"); + string untouchedEmptyDirectory = directory.CreateDirectory("untouched/empty"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget cleanedTarget = CreateTarget("cleaned", cleanedContent); + ContentIntegrityTarget untouchedTarget = CreateTarget("untouched", untouchedContent); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(cleanedTarget, "nested/unexpected.txt") + }); + + await service.ApplyCleanupAsync( + report, + new[] { cleanedTarget, untouchedTarget }, + CancellationToken.None); + + Directory.Exists(Path.Combine(cleanedContent, "nested")).Should().BeFalse(); + Directory.Exists(untouchedEmptyDirectory).Should().BeTrue(); + } + + [Fact] + public async Task ApplyCleanupAsync_MultipleTargets_SweepsEveryTargetWithDeletionsAsync() + { + using TestDirectory directory = new(); + string firstContent = directory.CreateDirectory("first"); + string secondContent = directory.CreateDirectory("second"); + directory.CreateFile("first/nested/unexpected.txt", "unexpected"); + directory.CreateFile("second/nested/unexpected.txt", "unexpected"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget firstTarget = CreateTarget("first", firstContent); + ContentIntegrityTarget secondTarget = CreateTarget("second", secondContent); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(firstTarget, "nested/unexpected.txt"), + CreateDeleteIssue(secondTarget, "nested/unexpected.txt") + }); + + await service.ApplyCleanupAsync( + report, + new[] { firstTarget, secondTarget }, + CancellationToken.None); + + Directory.Exists(Path.Combine(firstContent, "nested")).Should().BeFalse(); + Directory.Exists(Path.Combine(secondContent, "nested")).Should().BeFalse(); + } + + private static LauncherPaths CreatePaths(TestDirectory directory) + { + return TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + } + + private static FileSystemContentIntegrityService CreateService( + IAtomicFileWriter? atomicFileWriter = null) + { + return new FileSystemContentIntegrityService( + atomicFileWriter ?? new AtomicFileWriter(), + NullLogger.Instance); + } + + private static ContentIntegrityTarget CreateTarget( + string root, + ContentSourceKind sourceKind, + IReadOnlySet? ignoredRelativePaths = null) + { + return new ContentIntegrityTarget( + "target", + "Target", + root, + sourceKind, + ignoredRelativePaths ?? new HashSet(StringComparer.OrdinalIgnoreCase)); + } + + private static ContentIntegrityTarget CreateTarget(string id, string root) + { + return new ContentIntegrityTarget( + id, + id, + root, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase)); + } + + private static ContentIntegrityIssue CreateDeleteIssue( + ContentIntegrityTarget target, + string relativePath, + IntegrityIssueKind kind = IntegrityIssueKind.UnexpectedFile) + { + return new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + kind, + IntegrityIssueAction.Delete, + relativePath); + } + + private static string GetSnapshotPath(string snapshotDirectory, string targetId) + { + byte[] identifierHash = SHA256.HashData(Encoding.UTF8.GetBytes(targetId)); + return Path.Combine(snapshotDirectory, Convert.ToHexString(identifierHash) + ".json"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Integrity/Support/ContentIntegrityPathTests.cs b/GenLauncherGO.Tests/Infrastructure/Integrity/Support/ContentIntegrityPathTests.cs new file mode 100644 index 00000000..fb53a9f2 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Integrity/Support/ContentIntegrityPathTests.cs @@ -0,0 +1,24 @@ +using System; +using System.IO; +using GenLauncherGO.Infrastructure.Integrity.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Integrity.Support; + +public sealed class ContentIntegrityPathTests +{ + /// + /// Every relative path a scan produces is later resolved back against the target root and can be handed to a + /// delete, so one that already points outside the target must stop here rather than become a usable path. + /// + [Fact] + public void GetRelativePath_PathOutsideRoot_Throws() + { + using TestDirectory directory = new(); + string root = directory.CreateDirectory("content"); + string outsidePath = directory.CreateFile("outside.txt", "outside"); + + Action getRelativePath = () => ContentIntegrityPath.GetRelativePath(root, outsidePath); + + getRelativePath.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Integrity/Support/ContentIntegrityScannerTests.cs b/GenLauncherGO.Tests/Infrastructure/Integrity/Support/ContentIntegrityScannerTests.cs new file mode 100644 index 00000000..8469634a --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Integrity/Support/ContentIntegrityScannerTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Infrastructure.Integrity.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Integrity.Support; + +public sealed class ContentIntegrityScannerTests +{ + /// + /// Content the user has not installed yet is absent, not broken, so the scan describes an empty target instead + /// of failing on the missing directory. + /// + [Fact] + public async Task ScanAsync_MissingRootDirectory_ReturnsEmptyResultAsync() + { + using TestDirectory directory = new(); + ContentIntegrityTarget target = CreateTarget(directory.GetPath("missing")); + + ContentIntegrityScanResult scan = await ContentIntegrityScanner.ScanAsync(target, CancellationToken.None); + + scan.Files.Should().BeEmpty(); + scan.EmptyDirectories.Should().BeEmpty(); + scan.UnsafeLinks.Should().BeEmpty(); + scan.Errors.Should().BeEmpty(); + } + + [Fact] + public async Task ScanAsync_CancelledToken_ThrowsAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + directory.CreateFile("content/file.txt", "content"); + ContentIntegrityTarget target = CreateTarget(content); + using CancellationTokenSource cancellation = new(); + await cancellation.CancelAsync(); + + Func scan = () => ContentIntegrityScanner.ScanAsync(target, cancellation.Token); + + await scan.Should().ThrowAsync(); + } + + /// + /// Only a directory that actually holds nothing is an unexpected empty directory. A parent that merely contains + /// one would otherwise be reported alongside every leaf below it. + /// + [Fact] + public async Task ScanAsync_NestedDirectoryWithEntries_ReportsOnlyTheEmptyLeafAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + directory.CreateFile("content/nested/file.txt", "file"); + directory.CreateDirectory("content/nested/empty"); + ContentIntegrityTarget target = CreateTarget(content); + + ContentIntegrityScanResult scan = await ContentIntegrityScanner.ScanAsync(target, CancellationToken.None); + + scan.EmptyDirectories.Should().ContainSingle().Which.Should().Be("nested/empty"); + } + + /// + /// A link is one unsafe entry: reporting it twice would hand the cleanup a delete it cannot repeat, and + /// descending into it would read and trust whatever the link points at. + /// + [Fact] + public async Task ScanAsync_LinkedDirectory_ReportsUnsafeLinkOnceAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(content, "linked")); + ContentIntegrityTarget target = CreateTarget(content); + + ContentIntegrityScanResult scan = await ContentIntegrityScanner.ScanAsync(target, CancellationToken.None); + + scan.UnsafeLinks.Should().ContainSingle().Which.Should().Be("linked"); + scan.Files.Should().BeEmpty(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// A file the scan cannot read is not a file it can vouch for, so it becomes a reported error rather than an + /// entry that silently disappears from the scanned set. + /// + [Fact] + public async Task ScanAsync_UnreadableFile_ReportsScanErrorAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + string lockedPath = directory.CreateFile("content/locked.bin", "locked"); + ContentIntegrityTarget target = CreateTarget(content); + using FileStream exclusiveHandle = new(lockedPath, FileMode.Open, FileAccess.Read, FileShare.None); + + ContentIntegrityScanResult scan = await ContentIntegrityScanner.ScanAsync(target, CancellationToken.None); + + scan.Errors.Should().ContainSingle().Which.RelativePath.Should().Be("locked.bin"); + scan.Files.Should().BeEmpty(); + } + + /// + /// A directory the launcher cannot list is content it cannot account for. Reporting the directory as an error + /// is what keeps it from looking like an empty folder whose files have all gone missing. + /// + [Fact] + public async Task ScanAsync_UnreadableDirectory_ReportsScanErrorAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + string unreadablePath = directory.CreateDirectory("content/unreadable"); + ContentIntegrityTarget target = CreateTarget(content); + using DeniedDirectoryListing deniedListing = new(unreadablePath); + + ContentIntegrityScanResult scan = await ContentIntegrityScanner.ScanAsync(target, CancellationToken.None); + + scan.Errors.Should().ContainSingle().Which.RelativePath.Should().Be("unreadable"); + scan.EmptyDirectories.Should().BeEmpty(); + } + + private static ContentIntegrityTarget CreateTarget(string root) + { + return new ContentIntegrityTarget( + "target", + "Target", + root, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase)); + } +} + +/// +/// Denies the current account permission to list one directory for the duration of a test, which is the only way +/// to make a directory enumeration fail on demand without elevation. The deny entry is removed on disposal so the +/// owning temporary directory can still be enumerated and deleted. +/// +internal sealed class DeniedDirectoryListing : IDisposable +{ + private readonly FileSystemAccessRule _denyRule; + + private readonly DirectoryInfo _directory; + + public DeniedDirectoryListing(string path) + { + using var identity = WindowsIdentity.GetCurrent(); + SecurityIdentifier user = identity.User + ?? throw new InvalidOperationException("The current account has no user SID."); + + _directory = new DirectoryInfo(path); + _denyRule = new FileSystemAccessRule(user, FileSystemRights.ListDirectory, AccessControlType.Deny); + ChangeAccess(security => security.AddAccessRule(_denyRule)); + } + + public void Dispose() + { + ChangeAccess(security => security.RemoveAccessRule(_denyRule)); + } + + private void ChangeAccess(Action change) + { + DirectorySecurity security = _directory.GetAccessControl(); + change(security); + _directory.SetAccessControl(security); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/DeploymentLaunchPreparationServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/DeploymentLaunchPreparationServiceTests.cs new file mode 100644 index 00000000..c5d9b1d1 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/DeploymentLaunchPreparationServiceTests.cs @@ -0,0 +1,201 @@ +using System.IO; +using System.Linq; +using System.Threading; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Mods.Services; +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 DeploymentLaunchPreparationServiceTests +{ + [Fact] + public void Prepare_ResolvesSelectedVersionPathsAndUsesSelectionOrderForPrecedence() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + LauncherContentVersion[] versions = + [ + TestLauncherContent.Version("Rise", "1.0"), + TestLauncherContent.Version("Balance", "2.0", ModificationType.Patch, "Rise"), + TestLauncherContent.Version("Maps", "3.0", ModificationType.Addon, "Rise") + ]; + WriteVersionFile(paths, versions[0], "Data/file.ini", "mod"); + WriteVersionFile(paths, versions[1], "Data/file.ini", "patch"); + WriteVersionFile(paths, versions[2], "Data/file.ini", "addon"); + DeploymentLaunchPreparationService service = CreateService(); + + bool succeeded = service.Prepare( + new LaunchPreparationRequest( + paths, + versions, + false), + CancellationToken.None); + + succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("addon"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Prepare_RespectsBaseGameScriptSettingAndCleanupRestoresFiles(bool disableBaseGameScripts) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + LauncherContentVersion version = TestLauncherContent.Version("Rise", "1.0"); + Directory.CreateDirectory(GetVersionRoot(paths, version)); + string scriptsDirectory = Path.Combine(paths.GameDirectory, "Data", "Scripts"); + Directory.CreateDirectory(scriptsDirectory); + string[] scriptPaths = + [ + Path.Combine(scriptsDirectory, "MultiplayerScripts.scb"), + Path.Combine(scriptsDirectory, "SkirmishScripts.scb"), + Path.Combine(scriptsDirectory, "Scripts.ini") + ]; + foreach (string scriptPath in scriptPaths) + { + File.WriteAllText(scriptPath, Path.GetFileName(scriptPath)); + } + + DeploymentLaunchPreparationService service = CreateService(); + + bool prepareSucceeded = service.Prepare( + new LaunchPreparationRequest( + paths, + new[] { version }, + disableBaseGameScripts), + CancellationToken.None); + + prepareSucceeded.Should().BeTrue(); + foreach (string scriptPath in scriptPaths) + { + File.Exists(scriptPath).Should().Be(!disableBaseGameScripts); + } + + bool cleanupSucceeded = service.Cleanup(paths, CancellationToken.None); + + cleanupSucceeded.Should().BeTrue(); + foreach (string scriptPath in scriptPaths) + { + File.ReadAllText(scriptPath).Should().Be(Path.GetFileName(scriptPath)); + } + } + + [Fact] + public void Prepare_ReportsDeploymentFailure() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + DeploymentLaunchPreparationService service = CreateService(); + + bool succeeded = service.Prepare( + new LaunchPreparationRequest( + paths, + new[] { TestLauncherContent.Version("Missing", "1.0") }, + false), + CancellationToken.None); + + succeeded.Should().BeFalse(); + } + + [Fact] + public void Cleanup_WhenBackupIsCorrupt_ReturnsFalse() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + LauncherContentVersion version = TestLauncherContent.Version("Rise", "1.0"); + WriteVersionFile(paths, version, "Data/file.ini", "mod"); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "original"); + DeploymentLaunchPreparationService service = CreateService(); + service.Prepare( + new LaunchPreparationRequest(paths, new[] { version }, false), + CancellationToken.None).Should().BeTrue(); + string backupPath = Directory + .EnumerateFiles( + Path.Combine(paths.DeploymentDirectory, DeploymentStateStore.BackupsDirectoryName), + "*", + SearchOption.AllDirectories) + .Single(); + File.WriteAllText(backupPath, "corrupt"); + + bool succeeded = service.Cleanup(paths, CancellationToken.None); + + succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("mod"); + File.ReadAllText(backupPath).Should().Be("corrupt"); + } + + [Fact] + public void Recover_WhenManifestAndJournalAreUnreadable_ReturnsFalse() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "user data"); + File.WriteAllText(Path.Combine(paths.DeploymentDirectory, "active.json"), "{not-json"); + File.WriteAllText(Path.Combine(paths.DeploymentDirectory, "journal.jsonl"), "{also-not-json"); + DeploymentLaunchPreparationService service = CreateService(); + + bool succeeded = service.Recover(paths, CancellationToken.None); + + succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("user data"); + } + + [Fact] + public void Recover_RestoresJournaledBackupThroughLaunchPreparationBoundary() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackedUp( + "Data/file.ini", + "Backups/crash/Data/file.ini", + DeploymentJournalWriter.FingerprintFrom("original"), + "Backups/crash/Data/file.ini.partial")); + DeploymentLaunchPreparationService service = CreateService(); + + bool succeeded = service.Recover(paths, CancellationToken.None); + + succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("original"); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + private static DeploymentLaunchPreparationService CreateService() + { + var deploymentEngine = new FileSystemDeploymentService( + new FakeHardLinkCreator { CanCreateHardLinks = false }, + NullLogger.Instance); + return new DeploymentLaunchPreparationService(deploymentEngine); + } + + private static void WriteVersionFile( + LauncherPaths paths, + LauncherContentVersion version, + string relativePath, + string contents) + { + string filePath = Path.Combine(GetVersionRoot(paths, version), relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllText(filePath, contents); + } + + private static string GetVersionRoot(LauncherPaths paths, LauncherContentVersion version) + { + return LauncherContentPathResolver.ResolveVersionPath(paths, version.ContentKey)!.FullPath; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemDeploymentServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemDeploymentServiceTests.cs new file mode 100644 index 00000000..1c5a9ab0 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemDeploymentServiceTests.cs @@ -0,0 +1,1895 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class FileSystemDeploymentServiceTests +{ + [Fact] + public void Prepare_UsesHardLinkWhenCreatorSucceeds() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FakeHardLinkCreator hardLinks = new(); + FileSystemDeploymentService service = CreateService(hardLinks); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("mod"); + hardLinks.CreatedLinks.Should().ContainSingle(); + hardLinks.CreatedLinks[0].TargetPath.Should().NotBe(Path.Combine(paths.GameDirectory, "Data", "file.ini")); + File.Exists(hardLinks.CreatedLinks[0].TargetPath).Should().BeFalse(); + } + + [Fact] + public void Prepare_CopiesFileWhenHardLinkCreatorFails() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + DateTime packageWriteTimeUtc = new(2022, 4, 5, 6, 7, 8, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(Path.Combine(packageRoot, "Data", "file.ini"), packageWriteTimeUtc); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + File.ReadAllText(targetPath).Should().Be("mod"); + File.GetLastWriteTimeUtc(targetPath).Should().Be(packageWriteTimeUtc); + Directory.EnumerateFiles(paths.GameDirectory, "*.tmp", SearchOption.AllDirectories).Should().BeEmpty(); + } + + [Fact] + public void Prepare_WhenTheCopyStagingPathIsOccupied_FailsWithoutTouchingTheGameFile() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string? deployStagingPath = null; + FakeHardLinkCreator hardLinks = new() + { + CanCreateHardLinks = false, + SameVolumeCheck = (_, secondPath) => + { + if (!secondPath.Contains(".GenLauncherGO-deploy-", StringComparison.Ordinal)) + { + return; + } + + deployStagingPath = secondPath; + Directory.CreateDirectory(Path.GetDirectoryName(secondPath)!); + File.WriteAllText(secondPath, "squatter"); + } + }; + FileSystemDeploymentService service = CreateService(hardLinks); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + deployStagingPath.Should().NotBeNull(); + File.Exists(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().BeFalse(); + Directory.EnumerateFiles(paths.GameDirectory, "*.tmp", SearchOption.AllDirectories).Should().BeEmpty(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Prepare_WhenTheStagedHardLinkIsNotTheSourceFile_FailsWithoutDeployingIt() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FakeHardLinkCreator hardLinks = new() + { + UseRealHardLinks = false, + CreateHook = (targetPath, _) => + { + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "impostor"); + } + }; + FileSystemDeploymentService service = CreateService(hardLinks); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.Exists(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().BeFalse(); + Directory.EnumerateFiles(paths.GameDirectory, "*.tmp", SearchOption.AllDirectories).Should().BeEmpty(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Prepare_OverActiveDeployment_RestoresOriginalsBeforeDeployingNewPackages() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string firstPackageRoot = CreatePackage(paths, "First", ("Data/file.ini", "a")); + string secondPackageRoot = CreatePackage(paths, "Second", ("Data/file.ini", "b")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(firstPackageRoot, 0) }, + Array.Empty(), + CancellationToken.None).Succeeded.Should().BeTrue(); + + DeploymentResult secondPrepareResult = service.Prepare( + paths, + new[] { CreateDeploymentPackage(secondPackageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + secondPrepareResult.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("b"); + + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + Directory.Exists(Path.Combine(paths.DeploymentDirectory, "Backups")).Should().BeFalse(); + } + + [Fact] + public void Cleanup_RestoresBackedUpOriginalFile() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + 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 { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + 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 void PrepareAndCleanup_CrossVolumeFallbackRestoresOriginalContentAndMetadata() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + DateTime originalWriteTimeUtc = new(2012, 3, 4, 5, 6, 8, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(targetPath, originalWriteTimeUtc); + File.SetAttributes(targetPath, FileAttributes.ReadOnly | FileAttributes.Hidden); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FakeHardLinkCreator hardLinks = new() { PathsOnSameVolume = false }; + FileSystemDeploymentService service = CreateService(hardLinks); + + try + { + DeploymentResult prepareResult = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + prepareResult.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("mod"); + hardLinks.CreatedLinks.Should().BeEmpty(); + + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + File.GetLastWriteTimeUtc(targetPath).Should().Be(originalWriteTimeUtc); + File.GetAttributes(targetPath).Should().HaveFlag(FileAttributes.ReadOnly); + File.GetAttributes(targetPath).Should().HaveFlag(FileAttributes.Hidden); + Directory.Exists(Path.Combine(paths.DeploymentDirectory, "Backups")).Should().BeFalse(); + Directory.EnumerateFiles(paths.GameDirectory, "*.tmp", SearchOption.AllDirectories).Should().BeEmpty(); + } + finally + { + if (File.Exists(targetPath)) + { + File.SetAttributes(targetPath, FileAttributes.Normal); + } + } + } + + [Fact] + public void Cleanup_CrossVolumeRestoreCollisionHoldsTheBackupBytes_DeletesTheStagingFile() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string? restoreStagingPath = null; + FakeHardLinkCreator hardLinks = new() + { + CanCreateHardLinks = false, + PathsOnSameVolume = false, + SameVolumeCheck = (_, secondPath) => + { + if (!secondPath.Contains(".GenLauncherGO-restore-", StringComparison.Ordinal)) + { + return; + } + + restoreStagingPath = secondPath; + File.WriteAllText(secondPath, "original"); + File.SetAttributes(secondPath, FileAttributes.ReadOnly); + } + }; + FileSystemDeploymentService service = CreateService(hardLinks); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None).Succeeded.Should().BeTrue(); + + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeFalse(); + restoreStagingPath.Should().NotBeNull(); + File.Exists(restoreStagingPath!).Should().BeFalse(); + File.Exists(targetPath).Should().BeFalse(); + Directory.EnumerateFiles( + Path.Combine(paths.DeploymentDirectory, "Backups"), + "*", + SearchOption.AllDirectories) + .Should().ContainSingle(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeTrue(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeTrue(); + } + + [Fact] + public void Cleanup_CrossVolumeRestoreCollisionHoldsForeignBytes_LeavesTheStagingFileUntouched() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string? restoreStagingPath = null; + FakeHardLinkCreator hardLinks = new() + { + CanCreateHardLinks = false, + PathsOnSameVolume = false, + SameVolumeCheck = (_, secondPath) => + { + if (!secondPath.Contains(".GenLauncherGO-restore-", StringComparison.Ordinal)) + { + return; + } + + restoreStagingPath = secondPath; + File.WriteAllText(secondPath, "foreign"); + File.SetAttributes(secondPath, FileAttributes.ReadOnly); + } + }; + FileSystemDeploymentService service = CreateService(hardLinks); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None).Succeeded.Should().BeTrue(); + + try + { + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeFalse(); + restoreStagingPath.Should().NotBeNull(); + File.ReadAllText(restoreStagingPath!).Should().Be("foreign"); + File.Exists(targetPath).Should().BeFalse(); + Directory.EnumerateFiles( + Path.Combine(paths.DeploymentDirectory, "Backups"), + "*", + SearchOption.AllDirectories) + .Should().ContainSingle(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeTrue(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeTrue(); + } + finally + { + if (File.Exists(restoreStagingPath)) + { + File.SetAttributes(restoreStagingPath!, FileAttributes.Normal); + } + } + } + + [Fact] + public void Cleanup_CrossVolumeBackupChangesBeforeRestore_FailsClosedAndPreservesChangedBytes() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string? restoreStagingPath = null; + FakeHardLinkCreator hardLinks = new() + { + CanCreateHardLinks = false, + PathsOnSameVolume = false, + SameVolumeCheck = (sourcePath, secondPath) => + { + if (!secondPath.Contains(".GenLauncherGO-restore-", StringComparison.Ordinal)) + { + return; + } + + restoreStagingPath = secondPath; + File.WriteAllText(sourcePath, "tampered"); + } + }; + FileSystemDeploymentService service = CreateService(hardLinks); + DeploymentResult prepareResult = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + prepareResult.Succeeded.Should().BeTrue(); + + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeFalse(); + restoreStagingPath.Should().NotBeNull(); + File.ReadAllText(restoreStagingPath!).Should().Be("tampered"); + File.Exists(targetPath).Should().BeFalse(); + Directory.EnumerateFiles( + Path.Combine(paths.DeploymentDirectory, "Backups"), + "*", + SearchOption.AllDirectories) + .Should().ContainSingle(file => File.ReadAllText(file) == "tampered"); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeTrue(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeTrue(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Cleanup_BackupMissingOrCorrupt_FailsClosedAndPreservesRecoveryState(bool deleteBackup) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + string backupPath = Directory.EnumerateFiles( + Path.Combine(paths.DeploymentDirectory, "Backups"), + "*", + SearchOption.AllDirectories) + .Single(); + if (deleteBackup) + { + File.Delete(backupPath); + } + else + { + File.WriteAllText(backupPath, "corrupt"); + } + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("mod"); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeTrue(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeTrue(); + if (!deleteBackup) + { + File.ReadAllText(backupPath).Should().Be("corrupt"); + } + } + + [Fact] + public void Cleanup_RestoresEqualContentOriginalInsteadOfLeavingDeployedHardLink() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "same"); + DateTime originalWriteTimeUtc = new(2012, 3, 4, 5, 6, 8, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(targetPath, originalWriteTimeUtc); + File.SetAttributes(targetPath, FileAttributes.ReadOnly | FileAttributes.Hidden); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "same")); + string packageSourcePath = Path.Combine(packageRoot, "Data", "file.ini"); + File.SetLastWriteTimeUtc( + packageSourcePath, + new DateTime(2022, 4, 5, 6, 7, 8, DateTimeKind.Utc)); + FileSystemDeploymentService service = CreateService(new WindowsHardLinkCreator()); + + try + { + DeploymentResult prepareResult = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + prepareResult.Succeeded.Should().BeTrue(); + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeTrue(); + File.GetLastWriteTimeUtc(targetPath).Should().Be(originalWriteTimeUtc); + File.GetAttributes(targetPath).Should().HaveFlag(FileAttributes.ReadOnly); + File.GetAttributes(targetPath).Should().HaveFlag(FileAttributes.Hidden); + File.SetAttributes(packageSourcePath, FileAttributes.Normal); + File.WriteAllText(packageSourcePath, "package changed"); + File.ReadAllText(targetPath).Should().Be("same"); + } + finally + { + if (File.Exists(targetPath)) + { + File.SetAttributes(targetPath, FileAttributes.Normal); + } + + if (File.Exists(packageSourcePath)) + { + File.SetAttributes(packageSourcePath, FileAttributes.Normal); + } + } + } + + [Fact] + public void Cleanup_LeavesReplacementOfDeployedHardLinkUntouchedEvenWhenBytesMatch() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + FileSystemDeploymentService service = CreateService(new WindowsHardLinkCreator()); + DeploymentResult prepareResult = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + prepareResult.Succeeded.Should().BeTrue(); + + File.Delete(targetPath); + File.WriteAllText(targetPath, "mod"); + + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("mod"); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeTrue(); + } + + [Fact] + public void Prepare_CopiesReadOnlyPackageFileWithoutChangingPackageAttributes() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string packageSourcePath = Path.Combine(packageRoot, "Data", "file.ini"); + File.SetAttributes(packageSourcePath, FileAttributes.ReadOnly | FileAttributes.Hidden); + FakeHardLinkCreator hardLinks = new(); + FileSystemDeploymentService service = CreateService(hardLinks); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + + try + { + DeploymentResult prepareResult = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + prepareResult.Succeeded.Should().BeTrue(); + hardLinks.CreatedLinks.Should().BeEmpty(); + + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeTrue(); + File.Exists(targetPath).Should().BeFalse(); + File.GetAttributes(packageSourcePath).Should().HaveFlag(FileAttributes.ReadOnly); + File.GetAttributes(packageSourcePath).Should().HaveFlag(FileAttributes.Hidden); + } + finally + { + if (File.Exists(packageSourcePath)) + { + File.SetAttributes(packageSourcePath, FileAttributes.Normal); + } + } + } + + [Fact] + public void Cleanup_LeavesModifiedDeployedFileAndOriginalBackupUntouched() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + File.WriteAllText(targetPath, "user-change"); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("user-change"); + Directory.EnumerateFiles( + Path.Combine(paths.DeploymentDirectory, "Backups"), + "*", + SearchOption.AllDirectories) + .Should() + .ContainSingle(path => File.ReadAllText(path) == "original"); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeTrue(); + } + + [Fact] + public void Cleanup_DoesNotDeleteModifiedDeploymentWithoutOriginalBackup() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + File.WriteAllText(targetPath, "user-change"); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("user-change"); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeTrue(); + } + + [Fact] + public void Cleanup_DoesNotDeleteRestoredOriginalWhenManifestWasAlreadyApplied() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + string activeManifestPath = Path.Combine(paths.DeploymentDirectory, "active.json"); + string staleManifest = File.ReadAllText(activeManifestPath); + service.Cleanup(paths, CancellationToken.None); + File.WriteAllText(activeManifestPath, staleManifest); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + } + + [Fact] + public void Cleanup_RemovesCreatedDirectoriesOnlyWhenEmpty() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/Sub/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + File.WriteAllText(Path.Combine(paths.GameDirectory, "Data", "keep.txt"), "user"); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + 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 void Cleanup_RemovesEveryCreatedDirectoryDeepestFirstWhenNothingWasLeftBehind() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/Sub/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + Directory.Exists(Path.Combine(paths.GameDirectory, "Data", "Sub")).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.GameDirectory, "Data")).Should().BeFalse(); + } + + [Fact] + public void Prepare_DeploysGibSourceAsBigTarget() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("PatchData.gib", "archive")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + 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 void Prepare_LetsHigherPrecedencePackageWinTargetConflict() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string modRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string addonRoot = CreatePackage(paths, "Addon", ("Data/file.ini", "addon")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] + { + CreateDeploymentPackage(modRoot, 0), + CreateDeploymentPackage(addonRoot, 1) + }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("addon"); + } + + [Fact] + public void Prepare_DisablesExistingRequestedFilesAndCleanupRestoresThem() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string scriptsDirectory = Path.Combine(paths.GameDirectory, "Data", "Scripts"); + Directory.CreateDirectory(scriptsDirectory); + string multiplayerScripts = Path.Combine(scriptsDirectory, "MultiplayerScripts.scb"); + string scriptsIni = Path.Combine(scriptsDirectory, "Scripts.ini"); + File.WriteAllText(multiplayerScripts, "multiplayer"); + File.WriteAllText(scriptsIni, "scripts"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult prepareResult = service.Prepare( + paths, + Array.Empty(), + new[] + { + "Data/Scripts/MultiplayerScripts.scb", + "Data/Scripts/SkirmishScripts.scb", + "Data/Scripts/Scripts.ini" + }, + CancellationToken.None); + + prepareResult.Succeeded.Should().BeTrue(); + File.Exists(multiplayerScripts).Should().BeFalse(); + File.Exists(Path.Combine(scriptsDirectory, "SkirmishScripts.scb")).Should().BeFalse(); + File.Exists(scriptsIni).Should().BeFalse(); + + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeTrue(); + File.ReadAllText(multiplayerScripts).Should().Be("multiplayer"); + File.ReadAllText(scriptsIni).Should().Be("scripts"); + } + + [Fact] + public void Prepare_NormalizesAndDeduplicatesDisabledTargets() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string scriptsDirectory = Path.Combine(paths.GameDirectory, "Data", "Scripts"); + Directory.CreateDirectory(scriptsDirectory); + string scriptsIni = Path.Combine(scriptsDirectory, "Scripts.ini"); + File.WriteAllText(scriptsIni, "scripts"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + Array.Empty(), + new[] { @"Data\Scripts\Scripts.ini", "Data/Scripts/Scripts.ini", " Data/Scripts/Scripts.ini ", " " }, + CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.Exists(scriptsIni).Should().BeFalse(); + } + + [Fact] + public void Prepare_ReusesDisabledFileBackupWhenPackageDeploysSameTarget() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string scriptsDirectory = Path.Combine(paths.GameDirectory, "Data", "Scripts"); + Directory.CreateDirectory(scriptsDirectory); + string scriptsIni = Path.Combine(scriptsDirectory, "Scripts.ini"); + File.WriteAllText(scriptsIni, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/Scripts/Scripts.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult prepareResult = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + new[] { "Data/Scripts/Scripts.ini" }, + CancellationToken.None); + + prepareResult.Succeeded.Should().BeTrue(); + File.ReadAllText(scriptsIni).Should().Be("mod"); + + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeTrue(); + File.ReadAllText(scriptsIni).Should().Be("original"); + } + + [Fact] + public void Prepare_RecoversPartialDeploymentWhenLaterFileFails() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + 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 { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + result.Failures.Should().ContainSingle().Which.Should().Match(failure => + failure.Kind == DeploymentFailureKind.FileSystem && + failure.Path == paths.GameDirectory); + 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 void Prepare_DoesNotTranslateCancellationIntoFailure() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.Cancel(); + + Action act = () => service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + cancellationSource.Token); + + act.Should().Throw(); + } + + [Fact] + public void Prepare_CancellationAfterMutation_RecoversPartialDeploymentBeforeRethrowing() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage( + paths, + "Mod", + ("A/first.ini", "first"), + ("B/second.ini", "second")); + using var cancellationSource = new CancellationTokenSource(); + FileSystemDeploymentService service = CreateService( + new FakeHardLinkCreator { CanCreateHardLinks = false, CancelOn = cancellationSource }); + + Action act = () => service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + cancellationSource.Token); + + act.Should().Throw(); + File.Exists(Path.Combine(paths.GameDirectory, "A", "first.ini")).Should().BeFalse(); + File.Exists(Path.Combine(paths.GameDirectory, "B", "second.ini")).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Prepare_CancellationDuringFinalFile_RecoversBeforeRethrowing() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + using var cancellationSource = new CancellationTokenSource(); + FileSystemDeploymentService service = CreateService( + new FakeHardLinkCreator { CancelOn = cancellationSource }); + + Action act = () => service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + cancellationSource.Token); + + act.Should().Throw(); + File.Exists(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Prepare_FailsWhenDeploymentLockIsHeld() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string deploymentRoot = paths.DeploymentDirectory; + 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 { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.Exists(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().BeFalse(); + } + + [SymbolicLinkFact] + public void Prepare_FailsWhenDeploymentLockIsDanglingSymbolicLink() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + SymbolicLinkTestSupport.CreateFileLink( + Path.Combine(paths.DeploymentDirectory, "deployment.lock"), + Path.Combine(directory.Path, "missing-lock-target")); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.Exists(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().BeFalse(); + } + + [SymbolicLinkFact] + public void Prepare_FailsWhenDeploymentJournalIsDanglingSymbolicLink() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + SymbolicLinkTestSupport.CreateFileLink( + Path.Combine(paths.DeploymentDirectory, "journal.jsonl"), + Path.Combine(directory.Path, "missing-journal-target")); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.Exists(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().BeFalse(); + } + + [Fact] + public void Prepare_FailsWhenPackageTreeContainsReparsePoint() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + 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); + ReparsePointTestSupport.CreateDirectoryJunction(linkPath, linkTarget); + + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + } + + [Fact] + public void Prepare_FailsWhenGameTargetParentIsReparsePoint() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + 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); + ReparsePointTestSupport.CreateDirectoryJunction(linkedDataDirectory, linkedTarget); + + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.Exists(Path.Combine(linkedTarget, "file.ini")).Should().BeFalse(); + } + + [Theory] + [InlineData(DeploymentEntryPoint.Cleanup)] + [InlineData(DeploymentEntryPoint.Recover)] + public void CleanupAndRecover_JournalWithoutActiveManifest_RestoreTheBackedUpFile( + DeploymentEntryPoint entryPoint) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackedUp( + "Data/file.ini", + "Backups/crash/Data/file.ini", + DeploymentJournalWriter.FingerprintFrom("original"), + "Backups/crash/Data/file.ini.partial")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = entryPoint == DeploymentEntryPoint.Cleanup + ? service.Cleanup(paths, CancellationToken.None) + : service.Recover(paths, CancellationToken.None); + + 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 void Recover_RestoresBackupStartedFileWhenMoveCompletedBeforeBackedUpJournal() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackupStarted( + "Data/file.ini", + "Backups/crash/Data/file.ini", + "Backups/crash/Data/file.ini.partial")); + File.Move(targetPath, backupPath); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + 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 void Recover_IgnoresBackupStartedRecordWhenBackupWasNotCreated() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string deploymentRoot = paths.DeploymentDirectory; + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackupStarted( + "Data/file.ini", + "Backups/crash/Data/file.ini", + "Backups/crash/Data/file.ini.partial")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Recover_RestoresBackupWhenCleanupRestoreStartedAndBackupStillExists() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "mod"); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + string backupStagingPath = backupPath + ".partial"; + string deployStagingPath = Path.Combine(paths.GameDirectory, "Data", ".file.ini.deploy.tmp"); + string restoreStagingPath = Path.Combine(paths.GameDirectory, "Data", ".file.ini.restore.tmp"); + File.WriteAllText(backupStagingPath, "partial backup"); + File.WriteAllText(deployStagingPath, "incomplete deployment"); + File.WriteAllText(restoreStagingPath, "original"); + DeploymentFileFingerprint originalFingerprint = DeploymentJournalWriter.FingerprintFrom("original"); + DeploymentFileFingerprint modFingerprint = DeploymentJournalWriter.FingerprintFrom("mod"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackedUp( + "Data/file.ini", + "Backups/crash/Data/file.ini", + originalFingerprint, + "Backups/crash/Data/file.ini.partial"), + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + "Backups/crash/Data/file.ini", + modFingerprint, + originalFingerprint, + "Data/.file.ini.deploy.tmp"), + DeploymentJournalRecord.FileCleanupRestoreStarted( + "Data/file.ini", + "Backups/crash/Data/file.ini", + "Data/.file.ini.restore.tmp")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + File.Exists(backupPath).Should().BeFalse(); + File.Exists(backupStagingPath).Should().BeFalse(); + File.Exists(deployStagingPath).Should().BeFalse(); + File.Exists(restoreStagingPath).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Recover_TreatsMissingBackupAfterCleanupRestoreStartedAsRestored() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string deploymentRoot = paths.DeploymentDirectory; + File.WriteAllText(Path.Combine(deploymentRoot, "active.json"), "{not-json"); + DeploymentFileFingerprint originalFingerprint = DeploymentJournalWriter.FingerprintFrom("original"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackedUp( + "Data/file.ini", + "Backups/crash/Data/file.ini", + originalFingerprint, + "Backups/crash/Data/file.ini.partial"), + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + "Backups/crash/Data/file.ini", + DeploymentJournalWriter.FingerprintFrom("mod"), + originalFingerprint, + "Data/.file.ini.deploy.tmp"), + DeploymentJournalRecord.FileCleanupRestoreStarted( + "Data/file.ini", + "Backups/crash/Data/file.ini", + "Data/.file.ini.restore.tmp")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + 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 void Recover_KeepsNoBackupFileDeletedWhenCleanupDeleteCompleted() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + string deploymentRoot = paths.DeploymentDirectory; + File.WriteAllText(Path.Combine(deploymentRoot, "active.json"), "{not-json"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + null, + DeploymentJournalWriter.FingerprintFrom("mod"), + null, + "Data/.file.ini.deploy.tmp"), + DeploymentJournalRecord.FileCleanupDeleted("Data/file.ini")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + 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 void Recover_SkipsTruncatedTrailingJournalRecordAndReplaysPriorDurableState() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackedUp( + "Data/file.ini", + "Backups/crash/Data/file.ini", + DeploymentJournalWriter.FingerprintFrom("original"), + "Backups/crash/Data/file.ini.partial")); + string journalPath = Path.Combine(deploymentRoot, "journal.jsonl"); + File.AppendAllText(journalPath, "{\"action\":\"file-deployed\""); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("original"); + File.Exists(backupPath).Should().BeFalse(); + File.Exists(journalPath).Should().BeFalse(); + } + + [Fact] + public void Recover_WhenManifestAndJournalAreUnreadable_FailsClosedAndPreservesState() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "user data"); + string manifestPath = Path.Combine(paths.DeploymentDirectory, "active.json"); + string journalPath = Path.Combine(paths.DeploymentDirectory, "journal.jsonl"); + File.WriteAllText(manifestPath, "{not-json"); + File.WriteAllText(journalPath, "{also-not-json"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + result.Failures.Should().ContainSingle(failure => failure.Kind == DeploymentFailureKind.Manifest); + File.ReadAllText(targetPath).Should().Be("user data"); + File.Exists(manifestPath).Should().BeTrue(); + File.Exists(journalPath).Should().BeTrue(); + } + + [Fact] + public void Recover_EmptyJournal_SucceedsAndDeletesJournal() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "user data"); + string journalPath = Path.Combine(paths.DeploymentDirectory, "journal.jsonl"); + File.WriteAllText(journalPath, string.Empty); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("user data"); + File.Exists(journalPath).Should().BeFalse(); + } + + [Fact] + public void Recover_NullManifest_FailsAndPreservesManifest() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "user data"); + string manifestPath = Path.Combine(paths.DeploymentDirectory, "active.json"); + File.WriteAllText(manifestPath, "null"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("user data"); + File.Exists(manifestPath).Should().BeTrue(); + } + + [Theory] + [InlineData("Backups/crash/Data/file.ini.partial", true)] + [InlineData("", false)] + public void Recover_BackupStartedWithoutCompletedBackup_CleansRecordedPartialStagingFile( + string stagingRelativePath, + bool createStagingFile) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string stagingPath = Path.Combine(paths.DeploymentDirectory, "Backups", "crash", "Data", "file.ini.partial"); + if (createStagingFile) + { + Directory.CreateDirectory(Path.GetDirectoryName(stagingPath)!); + File.WriteAllText(stagingPath, "incomplete backup"); + File.SetAttributes(stagingPath, File.GetAttributes(stagingPath) | FileAttributes.ReadOnly); + } + + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackupStarted( + "Data/file.ini", + "Backups/crash/Data/file.ini", + stagingRelativePath)); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + File.Exists(stagingPath).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Recover_AcceptsSchemaTwoManifestWithRetiredReportingFields() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None).Succeeded.Should().BeTrue(); + string activeManifestPath = Path.Combine(paths.DeploymentDirectory, "active.json"); + JsonObject manifest = JsonNode.Parse(File.ReadAllText(activeManifestPath))!.AsObject(); + manifest["schemaVersion"]!.GetValue().Should().Be(2); + manifest["createdAtUtc"] = DateTimeOffset.UtcNow; + JsonObject file = manifest["files"]!.AsArray()[0]!.AsObject(); + file["sourcePath"] = Path.Combine(packageRoot, "Data", "file.ini"); + file["packageId"] = "mod::mod:1.0"; + file["size"] = 3; + file["lastWriteTimeUtc"] = DateTime.UtcNow; + File.WriteAllText(activeManifestPath, manifest.ToJsonString()); + File.Delete(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.Exists(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().BeFalse(); + File.Exists(activeManifestPath).Should().BeFalse(); + } + + [Fact] + public void Recover_AcceptsRetiredJournalFieldsWhenActiveManifestIsCorrupt() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string deployedPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "mod"); + string deploymentRoot = paths.DeploymentDirectory; + File.WriteAllText(Path.Combine(deploymentRoot, "active.json"), "{not-json"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + null, + DeploymentJournalWriter.FingerprintFrom("mod"), + null, + "Data/.file.ini.deploy.tmp")); + string journalPath = Path.Combine(deploymentRoot, "journal.jsonl"); + string[] journalLines = File.ReadAllLines(journalPath); + JsonObject deployedRecord = JsonNode.Parse(journalLines[1])!.AsObject(); + deployedRecord["sourcePath"] = "source"; + deployedRecord["packageId"] = "Mod"; + journalLines[1] = deployedRecord.ToJsonString(); + File.WriteAllLines(journalPath, journalLines); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.Exists(deployedPath).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "active.json")).Should().BeFalse(); + File.Exists(journalPath).Should().BeFalse(); + } + + [Fact] + public void Recover_RemovesFileFromStartedJournalRecord() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string deployedPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "mod"); + string deploymentRoot = paths.DeploymentDirectory; + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileDeploymentStarted( + "Data/file.ini", + null, + DeploymentJournalWriter.FingerprintFrom("mod"), + null, + "Data/.file.ini.deploy.tmp")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.Exists(deployedPath).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Recover_StartedJournalRecordForReadOnlyLink_KeepsThePackageFileReadOnly() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string packageSourcePath = Path.Combine(packageRoot, "Data", "file.ini"); + string deployedPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + new WindowsHardLinkCreator().TryCreateHardLink(deployedPath, packageSourcePath).Should().BeTrue(); + File.SetAttributes(packageSourcePath, FileAttributes.ReadOnly); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileDeploymentStarted( + "Data/file.ini", + null, + DeploymentJournalWriter.FingerprintFrom("mod"), + null, + "Data/.file.ini.deploy.tmp")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + try + { + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.GetAttributes(packageSourcePath).Should().HaveFlag(FileAttributes.ReadOnly); + File.ReadAllText(packageSourcePath).Should().Be("mod"); + } + finally + { + File.SetAttributes(packageSourcePath, FileAttributes.Normal); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Recover_ReplaysDirectoryCreationIntent(bool directoryWasCreated) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string createdDirectoryPath = Path.Combine(paths.GameDirectory, "Data", "Sub"); + if (directoryWasCreated) + { + Directory.CreateDirectory(createdDirectoryPath); + } + + DeploymentJournalWriter.Write(paths, DeploymentJournalRecord.DirectoryCreated("Data/Sub")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + Directory.Exists(createdDirectoryPath).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Recover_RefusesJournalBoundToDifferentPhysicalGameDirectory() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "mod"); + string otherGameDirectory = Path.Combine(directory.Path, "OtherGame"); + Directory.CreateDirectory(otherGameDirectory); + DeploymentJournalRecord[] records = + [ + DeploymentJournalRecord.DeploymentStarted( + "crash", + PhysicalDirectoryPath.ResolveExisting(otherGameDirectory), + DeploymentStateStore.GetGameRootIdentity(otherGameDirectory), + SupportedGame.Generals), + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + null, + DeploymentJournalWriter.FingerprintFrom("mod"), + null, + "Data/.file.ini.deploy.tmp") + ]; + var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); + File.WriteAllLines( + Path.Combine(paths.DeploymentDirectory, "journal.jsonl"), + records.Select(record => JsonSerializer.Serialize(record, serializerOptions))); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("mod"); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeTrue(); + } + + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("crash", true)] + public void CreatePaths_DeploymentIdShape_SelectsExpectedBackupOwnership( + string? deploymentId, + bool expectDeploymentSubdirectory) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string backupRoot = Path.Combine(paths.DeploymentDirectory, DeploymentStateStore.BackupsDirectoryName); + string expectedBackupPath = expectDeploymentSubdirectory + ? Path.Combine(backupRoot, deploymentId!) + : backupRoot; + + DeploymentStatePaths statePaths = DeploymentStateStore.CreatePaths(paths, deploymentId!); + + statePaths.BackupDirectory.Should().Be(expectedBackupPath); + } + + [Theory] + [InlineData("schema")] + [InlineData("schema-older")] + [InlineData("game")] + [InlineData("game-root")] + [InlineData("game-root-identity")] + public void Recover_StateBindingMismatch_FailsBeforeMutatingGameFile(string mismatch) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "mod"); + string gameRoot = PhysicalDirectoryPath.ResolveExisting(paths.GameDirectory); + string gameRootIdentity = DeploymentStateStore.GetGameRootIdentity(paths.GameDirectory); + var manifest = new DeploymentManifestDocument( + DeploymentStateStore.CurrentSchemaVersion, + "active", + [ + new DeploymentFileDocument( + "Data/file.ini", + DeploymentMethod.Copy, + null, + DeploymentJournalWriter.FingerprintFrom("mod")) + ], + Array.Empty(), + gameRoot, + gameRootIdentity, + paths.Game); + string otherGameRoot = directory.CreateDirectory("OtherGame"); + manifest = mismatch switch + { + "schema" => manifest with { SchemaVersion = DeploymentStateStore.CurrentSchemaVersion + 1 }, + "schema-older" => manifest with { SchemaVersion = DeploymentStateStore.CurrentSchemaVersion - 1 }, + "game" => manifest with { Game = SupportedGame.ZeroHour }, + "game-root" => manifest with { GameRoot = PhysicalDirectoryPath.ResolveExisting(otherGameRoot) }, + "game-root-identity" => manifest with { GameRootIdentity = "00000000:0000000000000000" }, + _ => throw new ArgumentOutOfRangeException(nameof(mismatch), mismatch, null) + }; + DeploymentStatePaths statePaths = DeploymentStateStore.CreatePaths(paths, string.Empty); + DeploymentStateStore.WriteManifest(statePaths.ActiveManifestPath, manifest); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("mod"); + File.Exists(statePaths.ActiveManifestPath).Should().BeTrue(); + } + + [SymbolicLinkFact] + public void Recover_LinkedJournal_RejectsBeforeReplayingExternalState() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "mod"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + null, + DeploymentJournalWriter.FingerprintFrom("mod"), + null, + "Data/.file.ini.deploy.tmp")); + string journalPath = Path.Combine(paths.DeploymentDirectory, "journal.jsonl"); + string externalJournalPath = directory.GetPath("external-journal.jsonl"); + File.Move(journalPath, externalJournalPath); + SymbolicLinkTestSupport.CreateFileLink(journalPath, externalJournalPath); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("mod"); + File.Exists(externalJournalPath).Should().BeTrue(); + } + + [SymbolicLinkFact] + public void Recover_LinkedManifest_RejectsBeforeFallingBackToJournal() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "mod"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + null, + DeploymentJournalWriter.FingerprintFrom("mod"), + null, + "Data/.file.ini.deploy.tmp")); + string manifestPath = Path.Combine(paths.DeploymentDirectory, "active.json"); + string externalManifestPath = directory.CreateFile("external-manifest.json", "null"); + SymbolicLinkTestSupport.CreateFileLink(manifestPath, externalManifestPath); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("mod"); + File.ReadAllText(externalManifestPath).Should().Be("null"); + } + + [Fact] + public void Recover_RefusesActiveManifestForDifferentGameRootWhenJournalIsEmpty() + { + using var directory = new TestDirectory(); + LauncherPaths originalPaths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(originalPaths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + DeploymentResult prepareResult = service.Prepare( + originalPaths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + prepareResult.Succeeded.Should().BeTrue(); + File.WriteAllText(Path.Combine(originalPaths.DeploymentDirectory, "journal.jsonl"), string.Empty); + + string otherGameDirectory = Path.Combine(directory.Path, "OtherGame"); + string otherTargetPath = Path.Combine(otherGameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(otherTargetPath)!); + File.WriteAllText(otherTargetPath, "mod"); + LauncherPaths otherPaths = new LauncherStoragePaths(Path.Combine(directory.Path, "Launcher")) + .CreateGamePaths(SupportedGame.Generals, otherGameDirectory); + + DeploymentResult result = service.Recover(otherPaths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(otherTargetPath).Should().Be("mod"); + File.Exists(Path.Combine(originalPaths.DeploymentDirectory, "active.json")).Should().BeTrue(); + } + + [Fact] + public void Prepare_WhenTheDeploymentStateDirectoryIsMissing_CreatesItAndDeploys() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + Directory.Delete(paths.DeploymentDirectory, true); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("mod"); + } + + [Fact] + public void Prepare_WhenTheTargetAppearsWhileStaging_FailsWithoutOverwritingIt() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + FakeHardLinkCreator hardLinks = new() + { + CreateHook = (_, _) => + { + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "written by the game"); + } + }; + FileSystemDeploymentService service = CreateService(hardLinks); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("written by the game"); + } + + [Fact] + public void Cleanup_RestoresTheOriginalOverAnUntouchedDeployedHardLink() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new WindowsHardLinkCreator()); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None) + .Succeeded.Should().BeTrue(); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + } + + [Fact] + public void Cleanup_WhenTheDeployedHardLinkWasReplacedWithMatchingBytes_KeepsTheBackedUpOriginal() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new WindowsHardLinkCreator()); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None) + .Succeeded.Should().BeTrue(); + File.Delete(targetPath); + File.WriteAllText(targetPath, "mod"); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("mod"); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeTrue(); + } + + [Fact] + public void Cleanup_WhenTheDeployedCopyAlreadyHoldsTheOriginalBytes_RestoresTheBackup() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None) + .Succeeded.Should().BeTrue(); + File.WriteAllText(targetPath, "original"); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + Directory.Exists(Path.Combine(paths.DeploymentDirectory, "Backups")).Should().BeFalse(); + } + + [Fact] + public void Recover_StartedDeploymentOverABackedUpFile_RestoresTheOriginal() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "mod"); + string backupPath = Path.Combine(paths.DeploymentDirectory, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + DeploymentFileFingerprint originalFingerprint = DeploymentJournalWriter.FingerprintFrom("original"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackedUp( + "Data/file.ini", + "Backups/crash/Data/file.ini", + originalFingerprint, + string.Empty), + DeploymentJournalRecord.FileDeploymentStarted( + "Data/file.ini", + "Backups/crash/Data/file.ini", + DeploymentJournalWriter.FingerprintFrom("mod"), + originalFingerprint, + "Data/.file.ini.deploy.tmp")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + } + + [Fact] + public void Recover_StartedDeploymentWhoseTargetWasModified_LeavesTheGameFileUntouched() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "user edited"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileDeploymentStarted( + "Data/file.ini", + null, + DeploymentJournalWriter.FingerprintFrom("mod"), + null, + "Data/.file.ini.deploy.tmp")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("user edited"); + } + + [Fact] + public void Recover_RemovesAReadOnlyRestoreStagingFileLeftByAnInterruptedCleanup() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "mod"); + string backupPath = Path.Combine(paths.DeploymentDirectory, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + string restoreStagingPath = Path.Combine(paths.GameDirectory, "Data", ".file.ini.restore.tmp"); + File.WriteAllText(restoreStagingPath, "original"); + File.SetAttributes(restoreStagingPath, FileAttributes.ReadOnly); + DeploymentFileFingerprint originalFingerprint = DeploymentJournalWriter.FingerprintFrom("original"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackedUp( + "Data/file.ini", + "Backups/crash/Data/file.ini", + originalFingerprint, + string.Empty), + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + "Backups/crash/Data/file.ini", + DeploymentJournalWriter.FingerprintFrom("mod"), + originalFingerprint, + "Data/.file.ini.deploy.tmp"), + DeploymentJournalRecord.FileCleanupRestoreStarted( + "Data/file.ini", + "Backups/crash/Data/file.ini", + "Data/.file.ini.restore.tmp")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + try + { + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.Exists(restoreStagingPath).Should().BeFalse(); + } + finally + { + if (File.Exists(restoreStagingPath)) + { + File.SetAttributes(restoreStagingPath, FileAttributes.Normal); + } + } + } + + /// + /// Writes the file the deployment tests target into the game folder, standing in for content a user + /// already has there before launch preparation runs. + /// + private static string CreateExistingGameFile(LauncherPaths paths, string contents) + { + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, contents); + return targetPath; + } + + private static FileSystemDeploymentService CreateService(IHardLinkCreator hardLinkCreator) + { + return new FileSystemDeploymentService( + hardLinkCreator, + NullLogger.Instance); + } + + 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 DeploymentPackage CreateDeploymentPackage( + string root, + int precedence) + { + return new DeploymentPackage(root, precedence); + } + + /// + /// The two entry points that replay the same persisted deployment state. + /// + public enum DeploymentEntryPoint + { + Cleanup, + Recover + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionServiceTests.cs new file mode 100644 index 00000000..cd34b46c --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionServiceTests.cs @@ -0,0 +1,1065 @@ +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.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Integrity.Contracts; +using GenLauncherGO.Infrastructure.Launching.Contracts; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Remote.Contracts; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class FileSystemLaunchContentIntegrityResolutionServiceTests +{ + [Fact] + public async Task VerifyAsync_BuildsTargetsAndVerifiesThemAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + ContentIntegrityTarget target = CreateTarget("package", Path.Combine(paths.ModsDirectory, "ShockWave", "1.2")); + LaunchContentIntegrityTargetContext[] contexts = + [ + new(target, version, false) + ]; + var report = new ContentIntegrityReport(Array.Empty()); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + integrityService.VerificationReport = report; + var targetBuilder = new StubLaunchContentIntegrityTargetBuilder(contexts); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + targetBuilder); + var request = new LaunchContentIntegrityTargetRequest( + paths, + new[] { version }, + new[] { version }, + "cache"); + + LaunchContentIntegrityVerificationResult result = await service.VerifyAsync( + request, + CancellationToken.None); + + result.Report.Should().BeSameAs(report); + result.TargetContexts.Should().Equal(contexts); + integrityService.VerifiedTargetSets.Should().ContainSingle(targets => + targets.Count == 1 && + targets[0] == target); + integrityService.VerifiedPaths.Should().ContainSingle().Which.Should().Be(paths); + } + + [Theory] + [InlineData("https://cdn.example.test/card.jpg", "1.2.jpg")] + [InlineData("https://cdn.example.test/card.webp", "1.2.png")] + public async Task InitializeUntrackedManagedCachesAsync_CapturesCacheWhenExpectedAssetsMatchAsync( + string imageSourceLink, + string expectedImageFileName) + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion( + ContentSourceKind.ManagedSingleFile, + imageSourceLink: imageSourceLink); + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + paths.GetModificationImagesDirectory("ShockWave")); + LaunchContentIntegrityTargetContext cacheContext = new(cacheTarget, version, true); + ContentIntegrityReport report = CreateReport( + "cache", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Redownload); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + integrityService.CaptureIfMatchesExpectedFileSetResult = true; + FileSystemLaunchContentIntegrityResolutionService service = CreateService(integrityService); + + bool initialized = await service.InitializeUntrackedManagedCachesAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { cacheContext }), + CancellationToken.None); + + initialized.Should().BeTrue(); + integrityService.ConditionalSnapshotRequests.Should().ContainSingle(request => + request.Target == cacheTarget && + request.ExpectedRelativePaths.SetEquals(new[] { expectedImageFileName })); + integrityService.ConditionalSnapshotPaths.Should().ContainSingle().Which.Should().Be(paths); + } + + [Fact] + public async Task InitializeUntrackedManagedCachesAsync_WhenCacheAlsoCarriesAnotherIssue_LeavesItForResolutionAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + paths.GetModificationImagesDirectory("ShockWave")); + LaunchContentIntegrityTargetContext cacheContext = new(cacheTarget, version, true); + ContentIntegrityReport report = CreateReport( + CreateIssue("cache", ContentSourceKind.ManagedSingleFile, IntegrityIssueKind.Untracked, + IntegrityIssueAction.Redownload), + CreateIssue("cache", ContentSourceKind.ManagedSingleFile, IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair)); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + integrityService.CaptureIfMatchesExpectedFileSetResult = true; + FileSystemLaunchContentIntegrityResolutionService service = CreateService(integrityService); + + bool initialized = await service.InitializeUntrackedManagedCachesAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { cacheContext }), + CancellationToken.None); + + initialized.Should().BeFalse(); + integrityService.ConditionalSnapshotRequests.Should().BeEmpty(); + } + + [Fact] + public async Task InitializeUntrackedManagedCachesAsync_WhenCacheContentsDoNotMatch_ReportsNoInitializationAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + paths.GetModificationImagesDirectory("ShockWave")); + LaunchContentIntegrityTargetContext cacheContext = new(cacheTarget, version, true); + ContentIntegrityReport report = CreateReport( + "cache", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Redownload); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + integrityService.CaptureIfMatchesExpectedFileSetResult = false; + FileSystemLaunchContentIntegrityResolutionService service = CreateService(integrityService); + + bool initialized = await service.InitializeUntrackedManagedCachesAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { cacheContext }), + CancellationToken.None); + + initialized.Should().BeFalse(); + integrityService.ConditionalSnapshotRequests.Should().ContainSingle(); + } + + [Fact] + public async Task InitializeUntrackedManagedCachesAsync_WithUntrackedPackage_LeavesItForResolutionAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2")); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Redownload); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + integrityService.CaptureIfMatchesExpectedFileSetResult = true; + FileSystemLaunchContentIntegrityResolutionService service = CreateService(integrityService); + + bool initialized = await service.InitializeUntrackedManagedCachesAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + CancellationToken.None); + + initialized.Should().BeFalse(); + integrityService.ConditionalSnapshotRequests.Should().BeEmpty(); + } + + [Fact] + public async Task ResolveAsync_RefreshesManagedCacheAndPreservesIgnoredFilesAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + string cacheRoot = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(cacheRoot); + string staleFilePath = Path.Combine(cacheRoot, "stale.txt"); + string ignoredFilePath = Path.Combine(cacheRoot, "ignored.txt"); + // A nested folder, so the sweep has to empty it from the deepest level upwards. Removing the child first is + // what lets its parent be empty in the same pass; the other order leaves the outer folder standing forever. + string staleDirectory = Path.Combine(cacheRoot, "Stale"); + string staleNestedDirectory = Path.Combine(staleDirectory, "Nested"); + Directory.CreateDirectory(staleNestedDirectory); + await File.WriteAllTextAsync(staleFilePath, "stale", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(ignoredFilePath, "ignored", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(staleNestedDirectory, "nested.txt"), "nested", TestContext.Current.CancellationToken); + + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + cacheRoot, + ContentSourceKind.ManagedSingleFile, + new HashSet(StringComparer.OrdinalIgnoreCase) { "ignored.txt" }); + LaunchContentIntegrityTargetContext cacheContext = new(cacheTarget, version, true); + ContentIntegrityReport report = CreateReport( + "cache", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + var assetDownloader = new RecordingRemoteAssetDownloader(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + assetDownloader: assetDownloader); + RecordingProgress progress = new(); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { cacheContext }), + progress, + CancellationToken.None); + + File.Exists(staleFilePath).Should().BeFalse(); + File.Exists(ignoredFilePath).Should().BeTrue(); + Directory.Exists(staleNestedDirectory).Should().BeFalse(); + Directory.Exists(staleDirectory).Should().BeFalse(); + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == new Uri("https://cdn.example.test/card.jpg") && + call.DestinationFilePath == Path.Combine(cacheRoot, "1.2.jpg")); + progress.Reports.Should().ContainSingle(report => + report.TargetId == "cache" && + report.Completed && + report.PackageProgress == null); + integrityService.CapturedTargets.Should().ContainSingle().Which.Should().Be(cacheTarget); + } + + [Fact] + public async Task ResolveAsync_RepairsManagedSingleFilePackageAndReportsProgressAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2")); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.MissingFile, + IntegrityIssueAction.Repair); + PackageUpdateProgress packageProgress = new(100, 40, 40, "package.zip"); + var singleFilePackageUpdater = new RecordingSingleFilePackageUpdater + { + ProgressToReport = packageProgress + }; + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + singleFilePackageUpdater: singleFilePackageUpdater); + RecordingProgress progress = new(); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + progress, + CancellationToken.None); + + (DownloadFileMetadata Metadata, PackageUpdatePathSet Paths) updateRequest = + singleFilePackageUpdater.Requests.Should().ContainSingle().Which; + updateRequest.Metadata.DownloadUri.Should().Be(new Uri("https://www.dropbox.com/s/package/file.zip?dl=1")); + updateRequest.Paths.TemporaryPath.FullPath.Should() + .Be(Path.Combine(paths.TempDirectory, "Packages", "ShockWave", "1.2")); + updateRequest.Paths.InstalledPath.FullPath.Should().Be(packageTarget.RootDirectory); + progress.Reports.Should().ContainSingle(report => + report.TargetId == "package" && + report.PackageProgress == packageProgress && + !report.Completed); + integrityService.CapturedTargets.Should().ContainSingle().Which.Should().Be(packageTarget); + } + + [Fact] + public async Task ResolveAsync_AppliesCleanupToEveryTargetBeforeRepairingAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2")); + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + paths.GetModificationImagesDirectory("ShockWave")); + LaunchContentIntegrityTargetContext[] contexts = + [ + new(packageTarget, version, false), + new(cacheTarget, version, true) + ]; + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.MissingFile, + IntegrityIssueAction.Repair); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + var singleFilePackageUpdater = new RecordingSingleFilePackageUpdater + { + Update = (_, _, _) => + { + integrityService.Calls.Add("repair"); + return Task.CompletedTask; + } + }; + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + singleFilePackageUpdater: singleFilePackageUpdater); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, contexts), + null, + CancellationToken.None); + + (ContentIntegrityReport Report, IReadOnlyList Targets) cleanupRequest = + integrityService.CleanupRequests.Should().ContainSingle().Which; + cleanupRequest.Report.Should().BeSameAs(report); + cleanupRequest.Targets.Should().Equal(packageTarget, cacheTarget); + integrityService.Calls.Should().Equal("cleanup", "repair", "capture"); + } + + [Fact] + public async Task ResolveAsync_TrustAsManualIssue_MarksVersionManualAndSnapshotsItAsManualAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.UnknownLegacy); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.UnknownLegacy); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.UnknownLegacy, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.TrustAsManual); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + var catalog = new FakeLauncherContentCatalog(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + catalog: catalog); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + null, + CancellationToken.None); + + version.Installation.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + catalog.SaveCount.Should().Be(1); + ContentIntegrityTarget capturedTarget = integrityService.CapturedTargets.Should().ContainSingle().Which; + capturedTarget.Id.Should().Be(packageTarget.Id); + capturedTarget.SourceKind.Should().Be(ContentSourceKind.Manual); + } + + [Fact] + public async Task ResolveAsync_AbsorbIssue_SnapshotsTheTargetWithoutRepairingItAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.Manual); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.Manual); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.Manual, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Absorb); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + var singleFilePackageUpdater = new RecordingSingleFilePackageUpdater(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + singleFilePackageUpdater: singleFilePackageUpdater); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + null, + CancellationToken.None); + + integrityService.CapturedTargets.Should().ContainSingle().Which.Should().BeSameAs(packageTarget); + version.Installation.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + singleFilePackageUpdater.Requests.Should().BeEmpty(); + } + + [Fact] + public async Task ResolveAsync_RepairsManagedS3PackageFileInPlaceUsingManifestAndTargetRootAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = TestLauncherContent.S3Version(version: "1.2"); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.ManagedS3); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair, + "Data/file.gib"); + RemoteFileManifestEntry[] files = + [ + new("Data/file.big", StubFileHashService.MatchingHash, 10), + new("Data/readme.txt", StubFileHashService.MatchingHash, 5) + ]; + var manifestReader = new RecordingS3ObjectManifestReader(); + manifestReader.Enqueue(files); + PackageUpdateProgress packageProgress = new(10, 10, 100, "Data/file.big"); + var s3PackageUpdater = new RecordingS3PackageUpdater { ProgressToReport = packageProgress }; + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + manifestReader: manifestReader, + s3PackageUpdater: s3PackageUpdater); + RecordingProgress progress = new(); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + progress, + CancellationToken.None); + + manifestReader.Requests.Should().ContainSingle().Which.Should().Match(request => + request.Endpoint == TestLauncherContent.S3Host && + request.BucketName == TestLauncherContent.S3Bucket && + request.Prefix == "ShockWave/1.2"); + S3PackageFileRepairRequest repairRequest = + s3PackageUpdater.RepairRequests.Should().ContainSingle().Which; + repairRequest.Files.Should().ContainSingle().Which.FileName.Should().Be("Data/file.big"); + repairRequest.Source.Should().BeSameAs(manifestReader.Requests.Single()); + repairRequest.InstalledPath.FullPath.Should().Be(packageTarget.RootDirectory); + repairRequest.InstalledPath.OwnerRoot.Should().Be(paths.ModsDirectory); + repairRequest.HashCheckedExtensions.Should().BeEquivalentTo(".big", ".txt", ".gib"); + progress.Reports.Should().ContainSingle(report => + report.TargetId == "package" && + report.PackageProgress == packageProgress); + integrityService.CapturedTargets.Should().ContainSingle().Which.Should().Be(packageTarget); + s3PackageUpdater.UpdateRequests.Should().BeEmpty(); + } + + [Fact] + public async Task ResolveAsync_RepairsUntrackedManagedS3PackageUsingFullReplacementAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = TestLauncherContent.S3Version(version: "1.2"); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.ManagedS3); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Repair, + "."); + RemoteFileManifestEntry[] files = + [ + new("Data/file.big", StubFileHashService.MatchingHash, 10), + new("Data/readme.txt", StubFileHashService.MatchingHash, 5) + ]; + var manifestReader = new RecordingS3ObjectManifestReader(); + manifestReader.Enqueue(files); + var s3PackageUpdater = new RecordingS3PackageUpdater(); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + manifestReader: manifestReader, + s3PackageUpdater: s3PackageUpdater); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + null, + CancellationToken.None); + + S3PackageUpdateRequest updateRequest = + s3PackageUpdater.UpdateRequests.Should().ContainSingle().Which; + updateRequest.Files.Should().Equal(files); + updateRequest.Source.Should().BeSameAs(manifestReader.Requests.Single()); + updateRequest.PathSet.InstalledPath.FullPath.Should().Be(packageTarget.RootDirectory); + updateRequest.PathSet.BackupPath.FullPath.Should().Be( + Path.Combine(paths.StateDirectory, "PackageBackups", "ShockWave", "1.2")); + updateRequest.PathSet.LatestInstalledPath!.FullPath.Should().Be(packageTarget.RootDirectory); + s3PackageUpdater.RepairRequests.Should().BeEmpty(); + integrityService.CapturedTargets.Should().ContainSingle().Which.Should().Be(packageTarget); + } + + /// + /// A package holding an extra file beside a modified one cannot be mended file by file: replacing the + /// modified file leaves the untracked one exactly where it was. Any issue that is not a file-level repair + /// therefore has to send the whole package down the replacement path, even when every reported path does + /// map to a manifest entry and a partial repair would otherwise look possible. + /// + [Fact] + public async Task ResolveAsync_UntrackedFileBesideAModifiedOne_ReplacesRatherThanRepairsAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = TestLauncherContent.S3Version(version: "1.2"); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.ManagedS3); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, false); + ContentIntegrityReport report = CreateReport( + CreateIssue( + "package", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair, + "Data/file.gib"), + CreateIssue( + "package", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Repair, + "Data/readme.txt")); + RemoteFileManifestEntry[] files = + [ + new("Data/file.big", StubFileHashService.MatchingHash, 10), + new("Data/readme.txt", StubFileHashService.MatchingHash, 5) + ]; + var manifestReader = new RecordingS3ObjectManifestReader(); + manifestReader.Enqueue(files); + var s3PackageUpdater = new RecordingS3PackageUpdater(); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + manifestReader: manifestReader, + s3PackageUpdater: s3PackageUpdater); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + null, + CancellationToken.None); + + s3PackageUpdater.RepairRequests.Should().BeEmpty( + "an untracked file survives an in-place repair, so the package has to be replaced whole"); + s3PackageUpdater.UpdateRequests.Should().ContainSingle().Which.Files.Should().Equal(files); + } + + [Fact] + public async Task RegisterManualImportAsync_MarksVersionManualAndCapturesPackageAndCacheSnapshotsAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.UnknownLegacy); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.Manual); + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + paths.GetModificationImagesDirectory("ShockWave"), + ContentSourceKind.Manual); + LaunchContentIntegrityTargetContext[] contexts = + [ + new(packageTarget, version, false), + new(cacheTarget, version, true) + ]; + var targetBuilder = new StubLaunchContentIntegrityTargetBuilder(contexts); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + var catalog = new FakeLauncherContentCatalog(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + targetBuilder, + catalog: catalog); + + await service.RegisterManualImportAsync( + new LaunchContentIntegrityVersionRequest(paths, version, new[] { version }, "cache"), + CancellationToken.None); + + version.Installation.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + catalog.SaveCount.Should().Be(1); + targetBuilder.Requests.Should().Contain(request => + request.Paths == paths && + request.ActiveVersions.Count == 1 && + request.ActiveVersions[0] == version && + request.CacheDisplayNameSuffix == "cache"); + integrityService.CapturedTargets.Should().BeEquivalentTo(new[] { packageTarget, cacheTarget }); + integrityService.CapturedPaths.Should().OnlyContain(capturedPaths => capturedPaths == paths); + } + + [Fact] + public async Task CaptureManagedInstallSnapshotAsync_RefreshesMismatchedThemedImageCacheAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion( + ContentSourceKind.ManagedSingleFile, + theme: new LauncherContentTheme + { + GenLauncherBackgroundImageLink = "https://cdn.example.test/background.png" + }); + LauncherContentVersion inactiveVersion = CreateVersion( + ContentSourceKind.ManagedSingleFile, + "1.1"); + string cacheRoot = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(cacheRoot); + string staleFilePath = Path.Combine(cacheRoot, "stale.txt"); + string inactiveImagePath = Path.Combine(cacheRoot, "1.1.jpg"); + string themeCachePath = Path.Combine( + cacheRoot, + LauncherContentTheme.ResolveCacheBaseName("1.2") + ".yaml"); + string backgroundPath = Path.Combine(cacheRoot, LauncherContentTheme.ResolveBackgroundImageBaseName("1.2") + ".png"); + await File.WriteAllTextAsync(staleFilePath, "stale", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(inactiveImagePath, "inactive", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(themeCachePath, "theme", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(backgroundPath, "old background", TestContext.Current.CancellationToken); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + var assetDownloader = new RecordingRemoteAssetDownloader + { + Handler = (_, destinationPath, cancellationToken) => + File.WriteAllTextAsync(destinationPath, "downloaded", cancellationToken) + }; + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + new FileSystemLaunchContentIntegrityTargetBuilder(), + assetDownloader: assetDownloader); + + await service.CaptureManagedInstallSnapshotAsync( + new LaunchContentIntegrityVersionRequest( + paths, + version, + new[] { version, inactiveVersion }, + "cache"), + CancellationToken.None); + + ContentIntegrityTarget packageTarget = integrityService.CapturedTargets[0]; + packageTarget.Id.Should().Be("package:mod::shockwave:1.2"); + packageTarget.RootDirectory.Should().Be(Path.Combine(paths.ModsDirectory, "ShockWave", "1.2")); + packageTarget.SourceKind.Should().Be(ContentSourceKind.ManagedSingleFile); + + (ContentIntegrityTarget Target, IReadOnlySet ExpectedRelativePaths) conditionalSnapshot = + integrityService.ConditionalSnapshotRequests.Should().ContainSingle().Which; + conditionalSnapshot.Target.Id.Should().Be("cache:mod::shockwave:1.2"); + conditionalSnapshot.Target.RootDirectory.Should().Be(cacheRoot); + conditionalSnapshot.Target.IgnoredRelativePaths.Should().BeEquivalentTo( + "1.1.jpg", + LauncherContentTheme.ResolveCacheBaseName("1.2") + ".yaml"); + conditionalSnapshot.ExpectedRelativePaths.Should().BeEquivalentTo( + "1.2.jpg", + LauncherContentTheme.ResolveBackgroundImageBaseName("1.2") + ".png"); + + File.Exists(staleFilePath).Should().BeFalse(); + File.Exists(inactiveImagePath).Should().BeTrue(); + File.Exists(themeCachePath).Should().BeTrue(); + (await File.ReadAllTextAsync(backgroundPath, TestContext.Current.CancellationToken)).Should().Be("downloaded"); + assetDownloader.Calls.Should().BeEquivalentTo(new[] + { + ( + new Uri("https://cdn.example.test/card.jpg"), + Path.Combine(cacheRoot, "1.2.jpg")), + ( + new Uri("https://cdn.example.test/background.png"), + Path.Combine(cacheRoot, LauncherContentTheme.ResolveBackgroundImageBaseName("1.2") + ".png")) + }); + integrityService.CapturedTargets.Should().HaveCount(2); + integrityService.CapturedTargets[1].Should().Be(conditionalSnapshot.Target); + } + + [Fact] + public async Task CaptureManualImageSnapshotAsync_CapturesOnlyResolvedManualImageCacheAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion( + ContentSourceKind.Manual, + simpleDownloadLink: string.Empty); + LauncherContentVersion inactiveVersion = CreateVersion( + ContentSourceKind.Manual, + "1.1", + string.Empty); + string cacheRoot = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(cacheRoot); + await File.WriteAllTextAsync(Path.Combine(cacheRoot, LauncherContentTheme.ResolveBackgroundImageBaseName("1.1") + ".png"), "inactive", TestContext.Current.CancellationToken); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + var catalog = new FakeLauncherContentCatalog(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + new FileSystemLaunchContentIntegrityTargetBuilder(), + catalog: catalog); + + await service.CaptureManualImageSnapshotAsync( + new LaunchContentIntegrityVersionRequest( + paths, + version, + new[] { version, inactiveVersion }, + "image cache"), + CancellationToken.None); + + ContentIntegrityTarget cacheTarget = integrityService.CapturedTargets.Should().ContainSingle().Which; + cacheTarget.Id.Should().Be("cache:mod::shockwave:1.2"); + cacheTarget.DisplayName.Should().Be("ShockWave 1.2 image cache"); + cacheTarget.RootDirectory.Should().Be(cacheRoot); + cacheTarget.SourceKind.Should().Be(ContentSourceKind.Manual); + cacheTarget.IgnoredRelativePaths.Should().ContainSingle().Which.Should().Be(LauncherContentTheme.ResolveBackgroundImageBaseName("1.1") + ".png"); + integrityService.ConditionalSnapshotRequests.Should().BeEmpty(); + catalog.SaveCount.Should().Be(0); + } + + [Fact] + public async Task InitializeUntrackedManagedCachesAsync_WithUntrackedManualCache_LeavesItForResolutionAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.Manual); + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + paths.GetModificationImagesDirectory("ShockWave"), + ContentSourceKind.Manual); + LaunchContentIntegrityTargetContext cacheContext = new(cacheTarget, version, true); + ContentIntegrityReport report = CreateReport( + "cache", + ContentSourceKind.Manual, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.TrustAsManual); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + integrityService.CaptureIfMatchesExpectedFileSetResult = true; + FileSystemLaunchContentIntegrityResolutionService service = CreateService(integrityService); + + bool initialized = await service.InitializeUntrackedManagedCachesAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { cacheContext }), + CancellationToken.None); + + initialized.Should().BeFalse(); + integrityService.ConditionalSnapshotRequests.Should().BeEmpty(); + } + + [Fact] + public async Task ResolveAsync_TrustAsManualIssuesForSeveralTargets_MarksEveryReportedVersionManualAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion firstVersion = CreateVersion(ContentSourceKind.UnknownLegacy); + LauncherContentVersion secondVersion = CreateVersion(ContentSourceKind.UnknownLegacy, "1.3"); + LaunchContentIntegrityTargetContext firstContext = new( + CreateTarget( + "package-first", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.UnknownLegacy), + firstVersion, + false); + LaunchContentIntegrityTargetContext secondContext = new( + CreateTarget( + "package-second", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.3"), + ContentSourceKind.UnknownLegacy), + secondVersion, + false); + ContentIntegrityReport report = CreateReport( + CreateIssue( + "package-first", + ContentSourceKind.UnknownLegacy, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.TrustAsManual), + CreateIssue( + "package-second", + ContentSourceKind.UnknownLegacy, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.TrustAsManual)); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService(integrityService); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { firstContext, secondContext }), + null, + CancellationToken.None); + + firstVersion.Installation.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + secondVersion.Installation.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + integrityService.CapturedTargets.Select(target => target.Id).Should() + .Equal("package-first", "package-second"); + } + + [Fact] + public async Task ResolveAsync_AbsorbIssuesForSeveralTargets_SnapshotsEveryReportedTargetAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + ContentIntegrityTarget firstTarget = CreateTarget( + "package-first", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.Manual); + ContentIntegrityTarget secondTarget = CreateTarget( + "package-second", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.3"), + ContentSourceKind.Manual); + LaunchContentIntegrityTargetContext firstContext = + new(firstTarget, CreateVersion(ContentSourceKind.Manual), false); + LaunchContentIntegrityTargetContext secondContext = + new(secondTarget, CreateVersion(ContentSourceKind.Manual, "1.3"), false); + ContentIntegrityReport report = CreateReport( + CreateIssue( + "package-first", + ContentSourceKind.Manual, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Absorb), + CreateIssue( + "package-second", + ContentSourceKind.Manual, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Absorb)); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService(integrityService); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { firstContext, secondContext }), + null, + CancellationToken.None); + + integrityService.CapturedTargets.Should().Equal(firstTarget, secondTarget); + } + + [Fact] + public async Task ResolveAsync_RepairIssuesForSeveralPackages_RepairsEveryReportedPackageAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LaunchContentIntegrityTargetContext firstContext = new( + CreateTarget( + "package-first", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.ManagedSingleFile), + CreateVersion(ContentSourceKind.ManagedSingleFile), + false); + LaunchContentIntegrityTargetContext secondContext = new( + CreateTarget( + "package-second", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.3"), + ContentSourceKind.ManagedSingleFile), + CreateVersion(ContentSourceKind.ManagedSingleFile, "1.3"), + false); + ContentIntegrityReport report = CreateReport( + CreateIssue( + "package-first", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair), + CreateIssue( + "package-second", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair)); + var singleFilePackageUpdater = new RecordingSingleFilePackageUpdater(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + singleFilePackageUpdater: singleFilePackageUpdater); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { firstContext, secondContext }), + null, + CancellationToken.None); + + singleFilePackageUpdater.Requests.Should().HaveCount(2); + } + + private static FileSystemLaunchContentIntegrityResolutionService CreateService( + IContentIntegrityService? integrityService = null, + ILaunchContentIntegrityTargetBuilder? targetBuilder = null, + IS3ObjectManifestReader? manifestReader = null, + IS3PackageUpdater? s3PackageUpdater = null, + ISingleFilePackageUpdater? singleFilePackageUpdater = null, + IRemoteAssetDownloader? assetDownloader = null, + ILauncherContentCatalog? catalog = null, + IDownloadFileMetadataReader? metadataReader = null) + { + var packageSourceResolver = new ManagedPackageSourceResolver( + metadataReader ?? new StubDownloadFileMetadataReader("package.zip", null), + manifestReader ?? new RecordingS3ObjectManifestReader(), + NullLogger.Instance); + return new FileSystemLaunchContentIntegrityResolutionService( + integrityService ?? CreateIntegrityService(), + targetBuilder ?? new StubLaunchContentIntegrityTargetBuilder(), + packageSourceResolver, + s3PackageUpdater ?? new RecordingS3PackageUpdater(), + singleFilePackageUpdater ?? new RecordingSingleFilePackageUpdater(), + assetDownloader ?? new RecordingRemoteAssetDownloader(), + catalog ?? new FakeLauncherContentCatalog(), + NullLogger.Instance); + } + + private static RecordingContentIntegrityService CreateIntegrityService() + { + return new RecordingContentIntegrityService(); + } + + private static ContentIntegrityReport CreateReport( + string targetId, + ContentSourceKind sourceKind, + IntegrityIssueKind issueKind, + IntegrityIssueAction action, + string relativePath = "Data/file.big") + { + return CreateReport(CreateIssue(targetId, sourceKind, issueKind, action, relativePath)); + } + + private static ContentIntegrityReport CreateReport(params ContentIntegrityIssue[] issues) + { + return new ContentIntegrityReport(issues); + } + + private static ContentIntegrityIssue CreateIssue( + string targetId, + ContentSourceKind sourceKind, + IntegrityIssueKind issueKind, + IntegrityIssueAction action, + string relativePath = "Data/file.big") + { + return new ContentIntegrityIssue( + targetId, + "ShockWave 1.2", + sourceKind, + issueKind, + action, + relativePath); + } + + private static ContentIntegrityTarget CreateTarget( + string id, + string rootDirectory, + ContentSourceKind sourceKind = ContentSourceKind.ManagedSingleFile, + IReadOnlySet? ignoredRelativePaths = null) + { + return new ContentIntegrityTarget( + id, + "ShockWave 1.2", + rootDirectory, + sourceKind, + ignoredRelativePaths ?? new HashSet(StringComparer.OrdinalIgnoreCase)); + } + + /// + /// Builds a version carrying the remote image link the shared content builder does not expose, because + /// is initialization-only. + /// + private static LauncherContentVersion CreateVersion( + ContentSourceKind sourceKind, + string version = "1.2", + string simpleDownloadLink = "https://www.dropbox.com/s/package/file.zip?dl=0", + string imageSourceLink = "https://cdn.example.test/card.jpg", + LauncherContentTheme? theme = null) + { + return new LauncherContentVersion + { + Installation = new LauncherContentInstallation { ContentSourceKind = sourceKind }, + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = version, + SimpleDownloadLink = simpleDownloadLink, + UIImageSourceLink = imageSourceLink, + Theme = theme + }; + } + + + private sealed class RecordingContentIntegrityService : IContentIntegrityService + { + public ContentIntegrityReport VerificationReport { get; set; } = + new(Array.Empty()); + + public bool CaptureIfMatchesExpectedFileSetResult { get; set; } + + public List> VerifiedTargetSets { get; } = []; + + public List VerifiedPaths { get; } = []; + + public List<( + ContentIntegrityTarget Target, + IReadOnlySet ExpectedRelativePaths)> ConditionalSnapshotRequests + { get; } = []; + + public List ConditionalSnapshotPaths { get; } = []; + + public List CapturedTargets { get; } = []; + + public List CapturedPaths { get; } = []; + + public List<( + ContentIntegrityReport Report, + IReadOnlyList Targets)> CleanupRequests + { get; } = []; + + /// + /// Names every observed call in order, so a test can pin that cleanup runs before a repair rewrites files. + /// + public List Calls { get; } = []; + + public Task VerifyAsync( + LauncherPaths paths, + IReadOnlyList targets, + CancellationToken cancellationToken) + { + Calls.Add("verify"); + VerifiedPaths.Add(paths); + VerifiedTargetSets.Add(targets); + return Task.FromResult(VerificationReport); + } + + public Task CaptureSnapshotIfMatchesExpectedFileSetAsync( + LauncherPaths paths, + ContentIntegrityTarget target, + IReadOnlySet expectedRelativePaths, + CancellationToken cancellationToken) + { + Calls.Add("conditional-capture"); + ConditionalSnapshotPaths.Add(paths); + ConditionalSnapshotRequests.Add((target, expectedRelativePaths)); + return Task.FromResult(CaptureIfMatchesExpectedFileSetResult); + } + + public Task CaptureSnapshotAsync( + LauncherPaths paths, + ContentIntegrityTarget target, + CancellationToken cancellationToken) + { + Calls.Add("capture"); + CapturedPaths.Add(paths); + CapturedTargets.Add(target); + return Task.CompletedTask; + } + + public Task ApplyCleanupAsync( + ContentIntegrityReport report, + IReadOnlyList targets, + CancellationToken cancellationToken) + { + Calls.Add("cleanup"); + CleanupRequests.Add((report, targets)); + return Task.CompletedTask; + } + } + + private sealed class StubLaunchContentIntegrityTargetBuilder : ILaunchContentIntegrityTargetBuilder + { + private readonly IReadOnlyList _contexts; + + public StubLaunchContentIntegrityTargetBuilder() + : this(Array.Empty()) + { + } + + public StubLaunchContentIntegrityTargetBuilder( + IReadOnlyList contexts) + { + _contexts = contexts; + } + + public List Requests { get; } = []; + + public IReadOnlyList BuildTargets( + LaunchContentIntegrityTargetRequest request) + { + Requests.Add(request); + return _contexts; + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilderTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilderTests.cs new file mode 100644 index 00000000..6b9b1528 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilderTests.cs @@ -0,0 +1,68 @@ +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; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class FileSystemLaunchContentIntegrityTargetBuilderTests +{ + [Fact] + public void Build_TargetsUsesLauncherOwnedTempPathsAndIgnoresInactiveCacheFiles() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + LauncherContentVersion activeVersion = CreateVersion("Rise", "1.0"); + LauncherContentVersion 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, LauncherContentTheme.ResolveBackgroundImageBaseName("0.9") + ".jpg"), "inactive background"); + var builder = new FileSystemLaunchContentIntegrityTargetBuilder(); + + IReadOnlyList targets = builder.BuildTargets( + new LaunchContentIntegrityTargetRequest( + paths, + new[] { activeVersion }, + new[] { activeVersion, inactiveVersion }, + "cache")); + + 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", LauncherContentTheme.ResolveBackgroundImageBaseName("0.9") + ".jpg"); + cacheTarget.Target.RootDirectory.Should().StartWith(paths.ImagesDirectory); + } + + [Fact] + public void BuildTargets_WhenTheImageCacheDirectoryIsMissing_IgnoresNothing() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + LauncherContentVersion activeVersion = CreateVersion("Rise", "1.0"); + LauncherContentVersion inactiveVersion = CreateVersion("Rise", "0.9"); + var builder = new FileSystemLaunchContentIntegrityTargetBuilder(); + + IReadOnlyList targets = builder.BuildTargets( + new LaunchContentIntegrityTargetRequest( + paths, + new[] { activeVersion }, + new[] { activeVersion, inactiveVersion }, + "cache")); + + targets.Single(target => target.IsCache).Target.IgnoredRelativePaths.Should().BeEmpty(); + } + + private static LauncherContentVersion CreateVersion(string name, string version) + { + return TestLauncherContent.Version(name, version, sourceKind: ContentSourceKind.Manual); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryServiceTests.cs new file mode 100644 index 00000000..3e0e1646 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryServiceTests.cs @@ -0,0 +1,223 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +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 WindowsGameExecutableDiscoveryServiceTests +{ + [Fact] + public void GetGameClients_ReturnsGeneralsOnlineThenCommunityThenRetailWithAvailability() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + CreateGameFile(paths, "generalszh.exe"); + CreateGameFile(paths, "generalsonlinezh.exe"); + CreateGameFile(paths, "generals.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + IReadOnlyList clients = service.GetGameClients(); + + clients.Should().HaveCount(3); + clients[0].ExecutableName.Should().Be("generalsonlinezh.exe"); + clients[0].Kind.Should().Be(BuiltInExecutableKind.GeneralsOnline); + clients[0].IsAvailable.Should().BeTrue(); + clients[1].ExecutableName.Should().Be("generalszh.exe"); + clients[1].Kind.Should().Be(BuiltInExecutableKind.Community); + clients[1].IsAvailable.Should().BeTrue(); + clients[2].ExecutableName.Should().Be("generals.exe"); + clients[2].Kind.Should().Be(BuiltInExecutableKind.Retail); + clients[2].IsAvailable.Should().BeTrue(); + } + + [Fact] + public void GetGameClients_KeepsMissingBuiltInsVisible() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + IReadOnlyList clients = service.GetGameClients(); + + clients.Select(client => client.ExecutableName).Should() + .Equal("generalsonlinezh.exe", "generalszh.exe", "generals.exe"); + clients.Should().OnlyContain(client => !client.IsAvailable); + } + + [Fact] + public void GetGameClients_UsesManagedGeneralsCommunityThenRetailExecutables() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create( + Path.Combine(directory.Path, "Game"), + SupportedGame.Generals); + CreateGameFile(paths, "generalsv.exe"); + CreateGameFile(paths, "generals.exe"); + CreateGameFile(paths, "generalsonlinezh.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + IReadOnlyList clients = service.GetGameClients(); + + clients.Should().HaveCount(2); + clients[0].ExecutableName.Should().Be("generalsv.exe"); + clients[0].Kind.Should().Be(BuiltInExecutableKind.Community); + clients[1].ExecutableName.Should().Be("generals.exe"); + clients[1].Kind.Should().Be(BuiltInExecutableKind.Retail); + } + + [Fact] + public void GetWorldBuilders_ReturnsRetailThenCommunityWhenPresent() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + CreateGameFile(paths, "WorldBuilder.exe"); + CreateGameFile(paths, "worldbuilderzh.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + IReadOnlyList worldBuilders = + service.GetWorldBuilders(); + + worldBuilders.Should().HaveCount(2); + worldBuilders[0].ExecutableName.Should().Be("WorldBuilder.exe"); + worldBuilders[0].Kind.Should().Be(BuiltInExecutableKind.Retail); + worldBuilders[0].IsAvailable.Should().BeTrue(); + worldBuilders[1].ExecutableName.Should().Be("worldbuilderzh.exe"); + worldBuilders[1].Kind.Should().Be(BuiltInExecutableKind.Community); + worldBuilders[1].IsAvailable.Should().BeTrue(); + } + + [Fact] + public void IsExecutableAvailable_ChecksRelativeNamesInGameDirectory() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + CreateGameFile(paths, "generalszh.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + bool available = service.IsExecutableAvailable("generalszh.exe"); + bool missing = service.IsExecutableAvailable("missing.exe"); + + available.Should().BeTrue(); + missing.Should().BeFalse(); + } + + [Fact] + public void IsExecutableAvailable_RejectsRootedExecutablePaths() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + string executablePath = Path.Combine(directory.Path, "external.exe"); + File.WriteAllText(executablePath, string.Empty); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + bool available = service.IsExecutableAvailable(executablePath); + + available.Should().BeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsExecutableAvailable_WithoutAnExecutableName_ReturnsFalse(string? executableName) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + bool available = service.IsExecutableAvailable(executableName); + + available.Should().BeFalse(); + } + + [Fact] + public void IsExecutableAvailable_WhenADirectoryCarriesTheExecutableName_ReturnsFalse() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + Directory.CreateDirectory(Path.Combine(paths.GameDirectory, "generalszh.exe")); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + bool available = service.IsExecutableAvailable("generalszh.exe"); + + available.Should().BeFalse(); + } + + [Fact] + public void IsExecutableAvailable_AcceptsRootLevelHardLink() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + Directory.CreateDirectory(paths.GameDirectory); + string sourcePath = directory.CreateFile("source.exe", string.Empty); + string hardLinkPath = Path.Combine(paths.GameDirectory, "custom.exe"); + new WindowsHardLinkCreator().TryCreateHardLink(hardLinkPath, sourcePath).Should().BeTrue(); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + service.IsExecutableAvailable("custom.exe").Should().BeTrue(); + } + + [SymbolicLinkFact] + public void IsExecutableAvailable_RejectsRootLevelSymbolicLink() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + Directory.CreateDirectory(paths.GameDirectory); + string targetPath = directory.CreateFile("target.exe", string.Empty); + SymbolicLinkTestSupport.CreateFileLink( + Path.Combine(paths.GameDirectory, "custom.exe"), + targetPath); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + service.IsExecutableAvailable("custom.exe").Should().BeFalse(); + } + + [Fact] + public void Discovery_UsesNewActiveInstallationWithoutRebuildingService() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + var storagePaths = new LauncherStoragePaths(executableDirectory); + LauncherPaths generalsPaths = storagePaths.CreateGamePaths( + SupportedGame.Generals, + directory.CreateDirectory("GeneralsGame")); + LauncherPaths zeroHourPaths = storagePaths.CreateGamePaths( + SupportedGame.ZeroHour, + directory.CreateDirectory("ZeroHourGame")); + CreateGameFile(generalsPaths, "generalsv.exe"); + CreateGameFile(zeroHourPaths, "generalszh.exe"); + var runtimePaths = new LauncherRuntimePathContext(storagePaths, generalsPaths); + var service = new WindowsGameExecutableDiscoveryService( + runtimePaths, + NullLogger.Instance); + + service.GetGameClients()[0] + .Should().Match(client => + client.ExecutableName == "generalsv.exe" && client.IsAvailable); + + runtimePaths.SwitchActive(zeroHourPaths); + + service.GetGameClients()[1] + .Should().Match(client => + client.ExecutableName == "generalszh.exe" && client.IsAvailable); + } + + private static WindowsGameExecutableDiscoveryService CreateService(LauncherPaths paths) + { + return new WindowsGameExecutableDiscoveryService( + TestLauncherPaths.CreateRuntimePathContext(paths), + NullLogger.Instance); + } + + private static void CreateGameFile(LauncherPaths paths, string fileName) + { + Directory.CreateDirectory(paths.GameDirectory); + File.WriteAllText(Path.Combine(paths.GameDirectory, fileName), string.Empty); + } + +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameProcessLauncherTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameProcessLauncherTests.cs new file mode 100644 index 00000000..4722d1af --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameProcessLauncherTests.cs @@ -0,0 +1,272 @@ +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.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class WindowsGameProcessLauncherTests +{ + [Fact] + public void GameLaunchRequest_RejectsExternalOrNestedExecutablePaths() + { + using var directory = new TestDirectory(); + + Action rooted = () => GameLaunchRequest.ForGameClient( + directory.Path, + Path.Combine(directory.Path, "external.exe"), + string.Empty); + Action nested = () => GameLaunchRequest.ForWorldBuilder( + directory.Path, + @"tools\custom.exe", + string.Empty); + Action traversal = () => GameLaunchRequest.ForGameClient( + directory.Path, + @"..\custom.exe", + string.Empty); + + rooted.Should().Throw(); + nested.Should().Throw(); + traversal.Should().Throw(); + } + + [Fact] + public async Task StartAsync_UsesExplicitGameExecutableAndArgumentsAsync() + { + using var directory = new TestDirectory(); + string executableName = directory.CreateFile("custom.exe", string.Empty); + var processLauncher = new RecordingProcessFamilyLauncher + { + RunningDuration = TimeSpan.FromSeconds(13) + }; + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + + bool succeeded = await StartAndCompleteAsync( + launcher, + GameLaunchRequest.ForGameClient(directory.Path, "custom.exe", "-quickstart"), + CancellationToken.None); + + succeeded.Should().BeTrue(); + processLauncher.Calls.Should().ContainSingle().Which.Should().Be( + (executableName, "-quickstart", directory.Path)); + } + + [Theory] + [InlineData(11_999, false)] + [InlineData(12_000, true)] + public async Task StartAsync_GameClientSuccessRequiresTwelveSecondDurationAsync( + int runningDurationMilliseconds, + bool expectedSuccess) + { + using var directory = new TestDirectory(); + directory.CreateFile("custom.exe", string.Empty); + var processLauncher = new RecordingProcessFamilyLauncher + { + RunningDuration = TimeSpan.FromMilliseconds(runningDurationMilliseconds) + }; + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + + bool succeeded = await StartAndCompleteAsync( + launcher, + GameLaunchRequest.ForGameClient(directory.Path, "custom.exe", string.Empty), + CancellationToken.None); + + succeeded.Should().Be(expectedSuccess); + } + + [Fact] + public async Task StartAsync_ShortGameLaunchWarningDoesNotExposeGameDirectoryAsync() + { + using var directory = new TestDirectory(); + directory.CreateFile("custom.exe", string.Empty); + var processLauncher = new RecordingProcessFamilyLauncher + { + RunningDuration = TimeSpan.FromSeconds(1) + }; + RecordingLogger logger = new(); + WindowsGameProcessLauncher launcher = new(processLauncher, logger); + + bool succeeded = await StartAndCompleteAsync( + launcher, + GameLaunchRequest.ForGameClient(directory.Path, "custom.exe", string.Empty), + CancellationToken.None); + + succeeded.Should().BeFalse(); + logger.Entries.Should().ContainSingle(entry => + entry.LogLevel == LogLevel.Warning && + entry.Message.Contains("custom.exe", StringComparison.Ordinal) && + !entry.Message.Contains(directory.Path, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task StartAsync_UsesExplicitWorldBuilderExecutableAndArgumentsAsync() + { + using var directory = new TestDirectory(); + string executableName = directory.CreateFile("custom-wb.exe", string.Empty); + var processLauncher = new RecordingProcessFamilyLauncher(); + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + + bool succeeded = await StartAndCompleteAsync( + launcher, + GameLaunchRequest.ForWorldBuilder(directory.Path, "custom-wb.exe", "-wb"), + CancellationToken.None); + + succeeded.Should().BeTrue(); + processLauncher.Calls.Should().ContainSingle().Which.Should().Be( + (executableName, "-wb", directory.Path)); + } + + [Fact] + public async Task ForceClose_StopsTheLaunchedProcessFamilyAsync() + { + using var directory = new TestDirectory(); + directory.CreateFile("custom.exe", string.Empty); + var processLauncher = new RecordingProcessFamilyLauncher { RunningDuration = null }; + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + IGameProcessLaunchOperation operation = await launcher.StartAsync( + GameLaunchRequest.ForGameClient(directory.Path, "custom.exe", string.Empty), + CancellationToken.None); + + operation.ForceClose(); + + processLauncher.StartedOperation!.ForceCloseCount.Should().Be(1); + } + + [Fact] + public async Task StartAsync_ForwardsCurrentExecutableNameChangesUntilTheGameExitsAsync() + { + using var directory = new TestDirectory(); + directory.CreateFile("custom.exe", string.Empty); + var processLauncher = new RecordingProcessFamilyLauncher { RunningDuration = null }; + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + List observedExecutableNames = []; + + IGameProcessLaunchOperation operation = await launcher.StartAsync( + GameLaunchRequest.ForGameClient(directory.Path, "custom.exe", string.Empty), + CancellationToken.None); + operation.CurrentExecutableNameChanged += (_, _) => + observedExecutableNames.Add(operation.CurrentExecutableName); + processLauncher.StartedOperation!.RaiseCurrentExecutableNameChanged("game.dat"); + processLauncher.StartedOperation.Complete(TimeSpan.FromSeconds(13)); + await operation.Completion; + processLauncher.StartedOperation.RaiseCurrentExecutableNameChanged("worldbuilder.exe"); + + observedExecutableNames.Should().Equal("game.dat"); + } + + [Fact] + public async Task StartAsync_PropagatesProcessFamilyFailureAsync() + { + using var directory = new TestDirectory(); + directory.CreateFile("custom.exe", string.Empty); + var failure = new InvalidOperationException("the launched process could not be observed"); + var processLauncher = new RecordingProcessFamilyLauncher { Failure = failure }; + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + + IGameProcessLaunchOperation operation = await launcher.StartAsync( + GameLaunchRequest.ForGameClient(directory.Path, "custom.exe", string.Empty), + CancellationToken.None); + Func> complete = () => operation.Completion; + + (await complete.Should().ThrowAsync()).Which.Should().BeSameAs(failure); + } + + [Fact] + public async Task StartAsync_RejectsMissingExecutableAsync() + { + using var directory = new TestDirectory(); + WindowsGameProcessLauncher launcher = CreateLauncher(new RecordingProcessFamilyLauncher()); + var request = GameLaunchRequest.ForGameClient( + directory.Path, + "missing.exe", + string.Empty); + + Func start = () => launcher.StartAsync(request, CancellationToken.None); + + await start.Should().ThrowAsync(); + } + + // Needs a file that is itself a reparse point, which only a symbolic link provides. A junction + // is a directory, so File.Exists short-circuits and production never reaches the reparse check. + [SymbolicLinkFact] + public async Task StartAsync_RejectsExecutableSymbolicLinkAsync() + { + using var directory = new TestDirectory(); + string targetPath = directory.CreateFile("target.bin", string.Empty); + SymbolicLinkTestSupport.CreateFileLink( + Path.Combine(directory.Path, "custom.exe"), + targetPath); + WindowsGameProcessLauncher launcher = CreateLauncher(new RecordingProcessFamilyLauncher()); + var request = GameLaunchRequest.ForGameClient( + directory.Path, + "custom.exe", + string.Empty); + + Func start = () => launcher.StartAsync(request, CancellationToken.None); + + await start.Should().ThrowAsync() + .WithMessage("*reparse point*"); + } + + private static WindowsGameProcessLauncher CreateLauncher(RecordingProcessFamilyLauncher processLauncher) + { + return new WindowsGameProcessLauncher( + processLauncher, + NullLogger.Instance); + } + + private static async Task StartAndCompleteAsync( + WindowsGameProcessLauncher launcher, + GameLaunchRequest request, + CancellationToken cancellationToken) + { + IGameProcessLaunchOperation operation = await launcher.StartAsync(request, cancellationToken); + return await operation.Completion; + } + + private sealed class RecordingProcessFamilyLauncher : IProcessFamilyLauncher + { + /// + /// Completes the launched operation immediately with this duration. Null leaves the operation running so a + /// test can drive itself. + /// + public TimeSpan? RunningDuration { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Fails the launched operation instead of completing it. + /// + public Exception? Failure { get; set; } + + public ControllableProcessFamilyLaunchOperation? StartedOperation { get; private set; } + + public List<(string ExecutableName, string Arguments, string WorkingDirectory)> Calls { get; } = []; + + public Task StartAsync( + string executableName, + string arguments, + string workingDirectory, + CancellationToken cancellationToken) + { + Calls.Add((executableName, arguments, workingDirectory)); + ControllableProcessFamilyLaunchOperation operation = new(executableName); + StartedOperation = operation; + if (Failure is not null) + { + operation.Completion = Task.FromException(Failure); + } + else if (RunningDuration is not null) + { + operation.Complete(RunningDuration.Value); + } + + return Task.FromResult(operation); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsProcessFamilyLauncherTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsProcessFamilyLauncherTests.cs new file mode 100644 index 00000000..26ec37b2 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsProcessFamilyLauncherTests.cs @@ -0,0 +1,655 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class WindowsProcessFamilyLauncherTests +{ + /// + /// A descendant that outlives every wait in these tests, so a launch only ends because the launcher stopped it. + /// + private const string LongRunningChildArguments = "/d /c ping.exe -n 60 127.0.0.1 >nul"; + + private const string ChildExecutableFileName = "ping.exe"; + + [Fact] + public async Task StartAsync_UsesArgumentsAndWorkingDirectoryBeforeCompletingAsync() + { + using TestDirectory directory = new(); + WindowsProcessFamilyLauncher launcher = new(NullLogger.Instance); + string executableName = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe"; + const string MarkerFileName = "launcher-marker.txt"; + + IProcessFamilyLaunchOperation operation = await launcher.StartAsync( + executableName, + $"/d /c echo launched>{MarkerFileName}", + directory.Path, + CancellationToken.None); + await operation.Completion.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + + File.ReadAllText(directory.GetPath(MarkerFileName)).Trim().Should().Be("launched"); + } + + [Fact] + public void ProcessFamilyTracker_StopsImmediatelyWhenStableNestedDescendantExits() + { + ManualTimeProvider clock = new(); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10), (30, 20)), + Snapshot((30, 20)), + Snapshot() + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + timeProvider: clock); + + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromSeconds(1)); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromSeconds(1)); + tracker.IsRunning().Should().BeFalse(); + tracker.RunningDuration.Should().Be(TimeSpan.FromSeconds(1)); + } + + [Fact] + public void ProcessFamilyTracker_AllowsHandoffChildFromRecentlyRetiredParent() + { + ManualTimeProvider clock = new(); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + Snapshot(), + Snapshot((30, 20)) + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + timeProvider: clock); + + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(250)); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(250)); + tracker.IsRunning().Should().BeTrue(); + } + + [Fact] + public void ProcessFamilyTracker_RejectsHandoffChildAfterParentRetirementExpires() + { + ManualTimeProvider clock = new(); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + Snapshot(), + Snapshot((30, 20)) + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + timeProvider: clock); + + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromSeconds(1)); + tracker.IsRunning().Should().BeFalse(); + } + + [Fact] + public void ProcessFamilyTracker_StopsImmediatelyWhenRootExitsWithoutChildren() + { + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + () => Snapshot()); + + bool result = tracker.IsRunning(); + + result.Should().BeFalse(); + } + + [Fact] + public void ProcessFamilyTracker_FallsBackToRootProcessWhenSnapshotsFail() + { + ManualTimeProvider clock = new(); + Queue rootRunningStates = new(new[] { true, false }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + () => null, + isProcessRunning: _ => rootRunningStates.Dequeue(), + timeProvider: clock); + + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromSeconds(3)); + tracker.IsRunning().Should().BeFalse(); + tracker.RunningDuration.Should().Be(TimeSpan.Zero); + } + + [Fact] + public void ProcessFamilyTrackerForceClose_TargetsTrackedRunningFamily() + { + List forceClosedProcessIds = []; + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + () => Snapshot((10, 1), (20, 10), (30, 20), (40, 99)), + forceCloseProcess: forceClosedProcessIds.Add); + tracker.IsRunning().Should().BeTrue(); + + tracker.ForceClose(); + + forceClosedProcessIds.Should().BeEquivalentTo(new[] { 10, 20, 30 }); + } + + [Fact] + public void ProcessFamilyTrackerForceClose_SkipsDescendantThatAlreadyExited() + { + List forceClosedProcessIds = []; + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10), (30, 20), (40, 99)), + Snapshot((10, 1), (30, 20), (40, 99)), + Snapshot((10, 1), (30, 20), (40, 99)) + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + forceCloseProcess: forceClosedProcessIds.Add); + tracker.IsRunning().Should().BeTrue(); + tracker.IsRunning().Should().BeTrue(); + + tracker.ForceClose(); + + forceClosedProcessIds.Should().BeEquivalentTo(new[] { 10, 30 }); + } + + [Fact] + public void ProcessFamilyTrackerForceClose_WhenSnapshotsFail_TargetsOnlyStillRunningTrackedProcesses() + { + List forceClosedProcessIds = []; + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + null + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + isProcessRunning: processId => processId == 10, + forceCloseProcess: forceClosedProcessIds.Add); + tracker.IsRunning().Should().BeTrue(); + + tracker.ForceClose(); + + forceClosedProcessIds.Should().BeEquivalentTo(new[] { 10 }); + } + + [Fact] + public void ProcessFamilyTracker_UpdatesCurrentExecutableToDeepestRunningDescendant() + { + Queue?> snapshots = new(new[] + { + NamedSnapshot((10, 1, "generalsonlinezh.exe")), + NamedSnapshot((10, 1, "generalsonlinezh.exe"), (20, 10, "generalszh.exe")), + NamedSnapshot( + (10, 1, "generalsonlinezh.exe"), + (20, 10, "generalszh.exe"), + (30, 20, "game.dat")) + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + rootExecutableName: "generalsonlinezh.exe", + captureProcessSnapshot: snapshots.Dequeue); + + tracker.CurrentExecutableName.Should().Be("generalsonlinezh.exe"); + tracker.IsRunning().Should().BeTrue(); + tracker.CurrentExecutableName.Should().Be("generalsonlinezh.exe"); + tracker.IsRunning().Should().BeTrue(); + tracker.CurrentExecutableName.Should().Be("generalszh.exe"); + tracker.IsRunning().Should().BeTrue(); + tracker.CurrentExecutableName.Should().Be("game.dat"); + } + + [Fact] + public void ProcessFamilyTracker_RootExecutableNameDoesNotExposeDirectory() + { + string executablePath = Path.Combine("C:", "Games", "Zero Hour", "generalszh.exe"); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + captureProcessSnapshot: Array.Empty, + rootExecutableName: executablePath); + + tracker.CurrentExecutableName.Should().Be("generalszh.exe"); + } + + [Fact] + public void ProcessFamilyTracker_StopsAfterChildHandoffExitsEvenWhenRootLauncherStillRuns() + { + ManualTimeProvider clock = new(); + Queue?> snapshots = new(new[] + { + NamedSnapshot((10, 1, "generalsonlinezh.exe"), (20, 10, "generalszh.exe")), + NamedSnapshot((10, 1, "generalsonlinezh.exe")), + NamedSnapshot((10, 1, "generalsonlinezh.exe")) + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + rootExecutableName: "generalsonlinezh.exe", + captureProcessSnapshot: snapshots.Dequeue, + timeProvider: clock); + + tracker.IsRunning().Should().BeTrue(); + tracker.CurrentExecutableName.Should().Be("generalszh.exe"); + clock.Advance(TimeSpan.FromSeconds(1)); + tracker.IsRunning().Should().BeFalse(); + } + + [Fact] + public async Task StartAsync_ReportsHowLongTheProcessFamilyRanAsync() + { + using TestDirectory directory = new(); + WindowsProcessFamilyLauncher launcher = new(NullLogger.Instance); + + IProcessFamilyLaunchOperation operation = await launcher.StartAsync( + CommandProcessorFileName, + "/d /c ping.exe -n 4 127.0.0.1 >nul", + directory.Path, + CancellationToken.None); + TimeSpan runningDuration = await operation.Completion.WaitAsync(TimeSpan.FromSeconds(25), TestContext.Current.CancellationToken); + + runningDuration.Should().BeGreaterThan(TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task StartAsync_ReportsTheDescendantExecutableWhileTheFamilyRunsAsync() + { + using TestDirectory directory = new(); + WindowsProcessFamilyLauncher launcher = new(NullLogger.Instance); + IProcessFamilyLaunchOperation operation = await launcher.StartAsync( + CommandProcessorFileName, + LongRunningChildArguments, + directory.Path, + CancellationToken.None); + + try + { + List reportedExecutableNames = []; + operation.CurrentExecutableNameChanged += (_, _) => + reportedExecutableNames.Add(operation.CurrentExecutableName); + + bool reportedTheChild = await WaitUntilAsync( + () => reportedExecutableNames.Exists(IsChildExecutableName), + TimeSpan.FromSeconds(12)); + + reportedTheChild.Should().BeTrue(); + } + finally + { + operation.ForceClose(); + await operation.Completion.WaitAsync(TimeSpan.FromSeconds(20), TestContext.Current.CancellationToken); + } + } + + [Fact] + public async Task StartAsync_DoesNotReportExecutableNameChangesWhileTheSameProcessRunsAsync() + { + using TestDirectory directory = new(); + WindowsProcessFamilyLauncher launcher = new(NullLogger.Instance); + int reportedNameChangeCount = 0; + + IProcessFamilyLaunchOperation operation = await launcher.StartAsync( + ChildExecutableFileName, + "-n 4 127.0.0.1", + directory.Path, + CancellationToken.None); + operation.CurrentExecutableNameChanged += (_, _) => Interlocked.Increment(ref reportedNameChangeCount); + await operation.Completion.WaitAsync(TimeSpan.FromSeconds(25), TestContext.Current.CancellationToken); + + reportedNameChangeCount.Should().Be(0); + } + + [Fact] + public async Task StartAsync_WhenTheLaunchIsCanceled_ReportsCancellationAsync() + { + using CancellationTokenSource cancellation = new(); + WindowsProcessFamilyLauncher launcher = new(NullLogger.Instance); + IProcessFamilyLaunchOperation operation = await launcher.StartAsync( + CommandProcessorFileName, + LongRunningChildArguments, + Path.GetTempPath(), + cancellation.Token); + + try + { + await cancellation.CancelAsync(); + + Func completion = () => operation.Completion.WaitAsync(TimeSpan.FromSeconds(15)); + await completion.Should().ThrowAsync(); + } + finally + { + operation.ForceClose(); + } + } + + [Fact] + public async Task ForceClose_StopsTheLaunchedProcessFamilyAsync() + { + using TestDirectory directory = new(); + WindowsProcessFamilyLauncher launcher = new(NullLogger.Instance); + IProcessFamilyLaunchOperation operation = await launcher.StartAsync( + CommandProcessorFileName, + LongRunningChildArguments, + directory.Path, + CancellationToken.None); + await WaitUntilAsync( + () => IsChildExecutableName(operation.CurrentExecutableName), + TimeSpan.FromSeconds(12)); + + operation.ForceClose(); + + Func familyExit = () => operation.Completion.WaitAsync(TimeSpan.FromSeconds(20)); + await familyExit.Should().NotThrowAsync(); + } + + [Fact] + public void ProcessFamilyTracker_ReportsMostRecentlyDiscoveredSiblingAsCurrentExecutable() + { + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + () => NamedSnapshot( + (10, 1, "generalsonlinezh.exe"), + (20, 10, "handoff-helper.exe"), + (30, 10, "generalszh.exe")), + rootExecutableName: "generalsonlinezh.exe"); + tracker.IsRunning().Should().BeTrue(); + + string currentExecutableName = tracker.CurrentExecutableName; + + currentExecutableName.Should().Be("generalszh.exe"); + } + + [Fact] + public void ProcessFamilyTracker_ReportsDeepestDescendantRatherThanNewestSiblingAsCurrentExecutable() + { + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + () => NamedSnapshot( + (10, 1, "generalsonlinezh.exe"), + (20, 10, "generalszh.exe"), + (30, 20, "game.dat"), + (40, 10, "handoff-helper.exe")), + rootExecutableName: "generalsonlinezh.exe"); + tracker.IsRunning().Should().BeTrue(); + + string currentExecutableName = tracker.CurrentExecutableName; + + currentExecutableName.Should().Be("game.dat"); + } + + [Fact] + public void ProcessFamilyTracker_KeepsTheDescendantNameWhileOnlyTheLauncherRootRemains() + { + Queue?> snapshots = new(new[] + { + NamedSnapshot((10, 1, "generalsonlinezh.exe"), (20, 10, "generalszh.exe")), + NamedSnapshot((10, 1, "generalsonlinezh.exe")) + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + rootExecutableName: "generalsonlinezh.exe"); + tracker.IsRunning().Should().BeTrue(); + tracker.IsRunning().Should().BeTrue(); + + string currentExecutableName = tracker.CurrentExecutableName; + + currentExecutableName.Should().Be("generalszh.exe"); + } + + /// + /// A process snapshot is not ordered parent-first, so a grandchild listed before its parent must still be + /// tracked instead of escaping the cleanup wait. + /// + [Fact] + public void ProcessFamilyTracker_TracksADescendantListedBeforeItsParent() + { + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + () => NamedSnapshot( + (30, 20, "game.dat"), + (20, 10, "generalszh.exe"), + (10, 1, "generalsonlinezh.exe")), + rootExecutableName: "generalsonlinezh.exe"); + tracker.IsRunning().Should().BeTrue(); + + string currentExecutableName = tracker.CurrentExecutableName; + + currentExecutableName.Should().Be("game.dat"); + } + + [Fact] + public void ProcessFamilyTracker_KeepsTheCurrentExecutableWhenADescendantHasNoName() + { + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + () => NamedSnapshot((10, 1, "generalszh.exe"), (20, 10, "")), + rootExecutableName: "generalszh.exe"); + tracker.IsRunning().Should().BeTrue(); + + string currentExecutableName = tracker.CurrentExecutableName; + + currentExecutableName.Should().Be("generalszh.exe"); + } + + [Fact] + public void ProcessFamilyTracker_WhenSnapshotsStopWorking_ReportsTheRootExecutableName() + { + Queue?> snapshots = new(new[] + { + NamedSnapshot((10, 1, "generalsonlinezh.exe"), (20, 10, "generalszh.exe")), + null + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + rootExecutableName: "generalsonlinezh.exe", + isProcessRunning: processId => processId == 10); + tracker.IsRunning().Should().BeTrue(); + tracker.CurrentExecutableName.Should().Be("generalszh.exe"); + + tracker.IsRunning().Should().BeTrue(); + + tracker.CurrentExecutableName.Should().Be("generalsonlinezh.exe"); + } + + [Fact] + public void ProcessFamilyTracker_MeasuresTheHandoffWindowFromTheDeepestRetiredDescendant() + { + ManualTimeProvider clock = new(); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + Snapshot((10, 1), (20, 10), (30, 20)), + Snapshot() + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + timeProvider: clock); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(400)); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(200)); + + bool running = tracker.IsRunning(); + + running.Should().BeTrue(); + } + + [Fact] + public void ProcessFamilyTracker_MeasuresTheHandoffWindowFromTheNewestRetiredSibling() + { + ManualTimeProvider clock = new(); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + Snapshot((10, 1), (20, 10), (30, 10)), + Snapshot() + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + timeProvider: clock); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(400)); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(200)); + + bool running = tracker.IsRunning(); + + running.Should().BeTrue(); + } + + [Fact] + public void ProcessFamilyTracker_ClosesTheHandoffWindowExactlyOneGracePeriodAfterTheChildWasSeen() + { + ManualTimeProvider clock = new(); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + Snapshot(), + Snapshot() + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + timeProvider: clock); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(300)); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(200)); + + bool running = tracker.IsRunning(); + + running.Should().BeFalse(); + } + + [Fact] + public void ProcessFamilyTracker_ForgetsARetiredParentExactlyOneGracePeriodAfterItExited() + { + ManualTimeProvider clock = new(); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + Snapshot(), + Snapshot((30, 20)) + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + timeProvider: clock); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(100)); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(500)); + + bool running = tracker.IsRunning(); + + running.Should().BeFalse(); + } + + [Fact] + public void ProcessFamilyTrackerForceClose_DiscoversDescendantsWithoutAPriorRunningCheck() + { + List forceClosedProcessIds = []; + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + () => Snapshot((10, 1), (20, 10), (30, 20), (40, 99)), + forceCloseProcess: forceClosedProcessIds.Add); + + tracker.ForceClose(); + + forceClosedProcessIds.Should().BeEquivalentTo(new[] { 10, 20, 30 }); + } + + private static string CommandProcessorFileName => + Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe"; + + private static bool IsChildExecutableName(string executableName) + { + return executableName.StartsWith("ping", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Polls until holds, so a test can observe the launcher's own background poll + /// loop without depending on how fast Windows starts the descendant process. + /// + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + DateTime deadlineUtc = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadlineUtc) + { + if (condition()) + { + return true; + } + + await Task.Delay(TimeSpan.FromMilliseconds(25)); + } + + return condition(); + } + + private static WindowsProcessFamilyLauncher.ProcessFamilyTracker CreateTracker( + int rootProcessId, + Func?> captureProcessSnapshot, + string rootExecutableName = "", + Func? isProcessRunning = null, + ManualTimeProvider? timeProvider = null, + Action? forceCloseProcess = null) + { + return new WindowsProcessFamilyLauncher.ProcessFamilyTracker( + rootProcessId, + rootExecutableName, + NullLogger.Instance, + captureProcessSnapshot, + isProcessRunning ?? (_ => false), + timeProvider ?? new ManualTimeProvider(), + TimeSpan.FromMilliseconds(500), + forceCloseProcess ?? (_ => { })); + } + + private static IReadOnlyList Snapshot( + params (int ProcessId, int ParentProcessId)[] entries) + { + List snapshot = []; + foreach ((int processId, int parentProcessId) in entries) + { + snapshot.Add(new WindowsProcessFamilyLauncher.ProcessSnapshotEntry( + processId, + parentProcessId)); + } + + return snapshot; + } + + private static IReadOnlyList NamedSnapshot( + params (int ProcessId, int ParentProcessId, string ExecutableFileName)[] entries) + { + List snapshot = []; + foreach ((int processId, int parentProcessId, string executableFileName) in entries) + { + snapshot.Add(new WindowsProcessFamilyLauncher.ProcessSnapshotEntry( + processId, + parentProcessId, + executableFileName)); + } + + return snapshot; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentFilePlannerTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentFilePlannerTests.cs new file mode 100644 index 00000000..5b580bc5 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentFilePlannerTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Infrastructure.Launching.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Support; + +public sealed class DeploymentFilePlannerTests +{ + [Fact] + public void ResolveDeploymentFiles_ExcludesExecutableCodeFromDownloadedPackages() + { + using TestDirectory directory = new(); + string packageRoot = directory.CreateDirectory("Package"); + string dataDirectory = Directory.CreateDirectory(Path.Combine(packageRoot, "Data")).FullName; + File.WriteAllText(Path.Combine(dataDirectory, "b.ini"), "second"); + File.WriteAllText(Path.Combine(dataDirectory, "a.ini"), "first"); + File.WriteAllText(Path.Combine(packageRoot, "Zeta.big"), "archive"); + File.WriteAllText(Path.Combine(packageRoot, "community-client.EXE"), "executable"); + File.WriteAllText(Path.Combine(packageRoot, "community-plugin.DlL"), "library"); + + IReadOnlyList result = DeploymentFilePlanner.ResolveDeploymentFiles( + new[] { new DeploymentPackage(packageRoot, 0) }); + + result.Select(file => file.TargetRelativePath) + .Should() + .Equal("Data/a.ini", "Data/b.ini", "Zeta.big"); + } + + [Fact] + public void ResolveDeploymentFiles_HigherPrecedencePackageReplacesSameTarget() + { + using TestDirectory directory = new(); + string modRoot = directory.CreateDirectory("Mod"); + string addonRoot = directory.CreateDirectory("Addon"); + Directory.CreateDirectory(Path.Combine(modRoot, "Data")); + Directory.CreateDirectory(Path.Combine(addonRoot, "Data")); + File.WriteAllText(Path.Combine(modRoot, "Data", "file.ini"), "mod"); + string addonSourcePath = Path.Combine(addonRoot, "Data", "file.ini"); + File.WriteAllText(addonSourcePath, "addon"); + + IReadOnlyList result = DeploymentFilePlanner.ResolveDeploymentFiles( + new[] + { + new DeploymentPackage(addonRoot, 1), + new DeploymentPackage(modRoot, 0) + }); + + result.Should().ContainSingle().Which.Should().Match(file => + file.TargetRelativePath == "Data/file.ini" && + file.SourcePath == addonSourcePath); + } + + [Fact] + public void ResolveDeploymentFiles_WhenAPackageDirectoryIsMissing_ReportsTheMissingDirectory() + { + using TestDirectory directory = new(); + string packageRoot = Path.Combine(directory.Path, "Missing"); + + Action resolve = () => DeploymentFilePlanner.ResolveDeploymentFiles( + new[] { new DeploymentPackage(packageRoot, 0) }); + + resolve.Should().Throw(); + } + + [Fact] + public void ResolveDeploymentFiles_WhenThePackageRootIsALink_RefusesToReadThroughIt() + { + using TestDirectory directory = new(); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(directory.Path, "Package")); + + Action resolve = () => DeploymentFilePlanner.ResolveDeploymentFiles( + new[] { new DeploymentPackage(junction.JunctionPath, 0) }); + + resolve.Should().Throw(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void GetDirectoriesToCreate_ReturnsMissingDirectoriesParentFirst() + { + using TestDirectory directory = new(); + string gameRoot = directory.CreateDirectory("Game"); + string targetDirectory = Path.Combine(gameRoot, "Data", "Sub"); + + IEnumerable directories = DeploymentFilePlanner.GetDirectoriesToCreate(gameRoot, targetDirectory); + + directories.Should().Equal( + Path.Combine(gameRoot, "Data"), + targetDirectory); + } + + [Fact] + public void GetDirectoriesToCreate_WhenTargetDirectoryExists_ReturnsNothing() + { + using TestDirectory directory = new(); + string gameRoot = directory.CreateDirectory("Game"); + string targetDirectory = Directory.CreateDirectory(Path.Combine(gameRoot, "Data", "Sub")).FullName; + + IEnumerable directories = DeploymentFilePlanner.GetDirectoriesToCreate(gameRoot, targetDirectory); + + directories.Should().BeEmpty(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentPathResolverTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentPathResolverTests.cs new file mode 100644 index 00000000..65ebd0cc --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentPathResolverTests.cs @@ -0,0 +1,125 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Support; + +public sealed class DeploymentPathResolverTests +{ + [Theory] + [InlineData(@"Data\INI\GameData.ini", "Data/INI/GameData.ini")] + [InlineData(@"Data//INI\\GameData.ini", "Data/INI/GameData.ini")] + [InlineData(" Data/INI/GameData.ini ", " Data/INI/GameData.ini ")] + public void NormalizeManifestPath_NormalizesSeparators(string relativePath, string expectedPath) + { + string result = DeploymentPathResolver.NormalizeManifestPath(relativePath); + + result.Should().Be(expectedPath); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void NormalizeManifestPath_RejectsMissingPaths(string relativePath) + { + Action act = () => DeploymentPathResolver.NormalizeManifestPath(relativePath); + + act.Should().Throw(); + } + + [Theory] + [InlineData(@"C:\Game\Data\GameData.ini", "Deployment manifest paths must be relative.")] + [InlineData("C:Game/Data/GameData.ini", "Deployment manifest paths must be relative.")] + [InlineData("../Data/GameData.ini", "Deployment manifest paths must not contain parent directory segments.")] + [InlineData("./Data/GameData.ini", "Deployment manifest paths must not contain parent directory segments.")] + public void NormalizeManifestPath_RejectsUnsafePaths(string relativePath, string expectedMessage) + { + Action act = () => DeploymentPathResolver.NormalizeManifestPath(relativePath); + + act.Should().Throw() + .WithMessage(expectedMessage); + } + + [Fact] + public void ResolveGamePath_ReturnsPathInsideGameDirectory() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + + string result = DeploymentPathResolver.ResolveGamePath(paths, @"Data\GameData.ini"); + + result.Should().Be(Path.GetFullPath(Path.Combine(paths.GameDirectory, "Data", "GameData.ini"))); + } + + [Fact] + public void ResolveGamePath_RejectsLauncherOwnedPaths() + { + using TestDirectory directory = new(); + string gameDirectory = directory.CreateDirectory("Game"); + string executableDirectory = directory.CreateDirectory(Path.Combine("Game", "GenLauncherGO")); + LauncherPaths paths = new LauncherStoragePaths(executableDirectory) + .CreateGamePaths(SupportedGame.ZeroHour, gameDirectory); + string launcherOwnedPath = Path.GetRelativePath( + paths.GameDirectory, + Path.Combine(paths.RuntimeDirectory, "state.yaml")); + + Action act = () => DeploymentPathResolver.ResolveGamePath( + paths, + launcherOwnedPath); + + act.Should().Throw() + .WithMessage("*outside the game directory*"); + } + + [Fact] + public void ToRelativeManifestPath_ReturnsNormalizedChildPath() + { + using TestDirectory directory = new(); + string rootDirectory = Path.Combine(directory.Path, "Package"); + string path = Path.Combine(rootDirectory, "Data", "GameData.ini"); + + string result = DeploymentPathResolver.ToRelativeManifestPath(rootDirectory, path); + + result.Should().Be("Data/GameData.ini"); + } + + [Fact] + public void ToRelativeManifestPath_RejectsPathsOutsideRoot() + { + using TestDirectory directory = new(); + string rootDirectory = Path.Combine(directory.Path, "Package"); + string path = Path.Combine(directory.Path, "Other", "GameData.ini"); + + Action act = () => DeploymentPathResolver.ToRelativeManifestPath(rootDirectory, path); + + act.Should().Throw(); + } + + [Fact] + public void ResolveDeploymentStatePath_ReturnsPathInsideDeploymentDirectory() + { + using TestDirectory directory = new(); + string deploymentDirectory = Path.Combine(directory.Path, "Deployment"); + + string result = DeploymentPathResolver.ResolveDeploymentStatePath( + deploymentDirectory, + @"Records\manifest.yaml"); + + result.Should().Be(Path.GetFullPath(Path.Combine(deploymentDirectory, "Records", "manifest.yaml"))); + } + + [Fact] + public void ResolveDeploymentStatePath_RejectsPathsOutsideDeploymentDirectory() + { + using TestDirectory directory = new(); + string deploymentDirectory = Path.Combine(directory.Path, "Deployment"); + + Action act = () => DeploymentPathResolver.ResolveDeploymentStatePath( + deploymentDirectory, + "../manifest.yaml"); + + act.Should().Throw(); + } + +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Support/WindowsHardLinkCreatorTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Support/WindowsHardLinkCreatorTests.cs new file mode 100644 index 00000000..60c520c5 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Support/WindowsHardLinkCreatorTests.cs @@ -0,0 +1,109 @@ +using System.IO; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Launching.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Support; + +public sealed class WindowsHardLinkCreatorTests +{ + private const string ExtendedLengthPrefix = @"\\?\"; + + [Fact] + public void ArePathsOnSameVolume_ReturnsTrueForFilesUnderSameVolume() + { + using TestDirectory directory = new(); + string firstPath = Path.Combine(directory.Path, "First", "one.big"); + string secondPath = Path.Combine(directory.Path, "Second", "two.big"); + Directory.CreateDirectory(Path.GetDirectoryName(firstPath)!); + Directory.CreateDirectory(Path.GetDirectoryName(secondPath)!); + WindowsHardLinkCreator creator = new(); + + bool sameVolume = creator.ArePathsOnSameVolume(firstPath, secondPath); + + sameVolume.Should().BeTrue(); + } + + [Fact] + public void TryCreateHardLink_CreatesNonReparseHardLinkToExistingFile() + { + using TestDirectory directory = new(); + string sourcePath = Path.Combine(directory.Path, "source.big"); + string targetPath = Path.Combine(directory.Path, "target.big"); + File.WriteAllText(sourcePath, "package"); + WindowsHardLinkCreator creator = new(); + + bool created = creator.TryCreateHardLink(targetPath, sourcePath); + + created.Should().BeTrue(); + File.Exists(targetPath).Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("package"); + File.GetAttributes(targetPath).Should().NotHaveFlag(FileAttributes.ReparsePoint); + + File.WriteAllText(sourcePath, "updated through source"); + File.ReadAllText(targetPath).Should().Be("updated through source"); + + File.WriteAllText(targetPath, "updated through target"); + File.ReadAllText(sourcePath).Should().Be("updated through target"); + } + + [Fact] + public void TryCreateHardLink_ReturnsFalseWhenSourceIsMissing() + { + using TestDirectory directory = new(); + string sourcePath = Path.Combine(directory.Path, "missing.big"); + string targetPath = Path.Combine(directory.Path, "target.big"); + WindowsHardLinkCreator creator = new(); + + bool created = creator.TryCreateHardLink(targetPath, sourcePath); + + created.Should().BeFalse(); + File.Exists(targetPath).Should().BeFalse(); + } + + /// + /// A caller may already hold Win32 extended-length paths, which must not be prefixed a second time. + /// + [Fact] + public void TryCreateHardLink_CreatesLinkWhenPathsAreAlreadyExtendedLength() + { + using TestDirectory directory = new(); + string sourcePath = Path.Combine(directory.Path, "source.big"); + string targetPath = Path.Combine(directory.Path, "target.big"); + File.WriteAllText(sourcePath, "package"); + WindowsHardLinkCreator creator = new(); + + bool created = creator.TryCreateHardLink( + ExtendedLengthPrefix + targetPath, + ExtendedLengthPrefix + sourcePath); + + created.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("package"); + PhysicalDirectoryPath.GetFileIdentity(targetPath).Should() + .Be(PhysicalDirectoryPath.GetFileIdentity(sourcePath)); + } + + [Fact] + public void TryCreateHardLink_CreatesLinkWhenSourcePathExceedsLegacyMaxPath() + { + using TestDirectory directory = new(); + string longDirectory = directory.Path; + while (longDirectory.Length < 275) + { + longDirectory = Path.Combine(longDirectory, "long-package-directory"); + } + + Directory.CreateDirectory(longDirectory); + string sourcePath = Path.Combine(longDirectory, "source.big"); + string targetPath = Path.Combine(directory.Path, "target.big"); + File.WriteAllText(sourcePath, "package"); + WindowsHardLinkCreator creator = new(); + + bool created = creator.TryCreateHardLink(targetPath, sourcePath); + + sourcePath.Length.Should().BeGreaterThan(260); + created.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("package"); + PhysicalDirectoryPath.GetFileIdentity(targetPath).Should() + .Be(PhysicalDirectoryPath.GetFileIdentity(sourcePath)); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Logging/LoggingServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/Infrastructure/Logging/LoggingServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..37af05da --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Logging/LoggingServiceCollectionExtensionsTests.cs @@ -0,0 +1,228 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using GenLauncherGO.Infrastructure.Logging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Tests.Infrastructure.Logging; + +public sealed partial class LoggingServiceCollectionExtensionsTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(true, true)] + public void AddGenLauncherGoLogging_DiagnosticSettingControlsDebugEvents( + bool enableDiagnosticLogging, + bool expectDebugEvent) + { + using TestDirectory directory = new(); + string logDirectory = directory.GetPath("Logs"); + var services = new ServiceCollection(); + + services.AddGenLauncherGoLogging(logDirectory, enableDiagnosticLogging); + using (ServiceProvider provider = services.BuildServiceProvider()) + { + provider + .GetRequiredService>() + .LogDebug("Detailed diagnostic event"); + } + + string logText = File.ReadAllText(Directory.GetFiles(logDirectory, "GenLauncherGO-*.log").Single()); + logText.Contains("Detailed diagnostic event", StringComparison.Ordinal).Should().Be(expectDebugEvent); + } + + [Fact] + public void AddGenLauncherGoLogging_CreatesReadableSessionLog() + { + using TestDirectory directory = new(); + string logDirectory = directory.GetPath("Logs"); + + 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); + } + + Directory.Exists(logDirectory).Should().BeTrue(); + string[] logFiles = Directory.GetFiles(logDirectory, "GenLauncherGO-*.log"); + logFiles.Should().HaveCount(2); + logFiles.Should().OnlyContain(file => + SessionLogFileNamePattern().IsMatch(Path.GetFileName(file))); + } + + [Fact] + public void AddGenLauncherGoLogging_PrunesOldSessionLogs() + { + using TestDirectory directory = new(); + string logDirectory = directory.CreateDirectory("Logs"); + string[] oldLogFileNames = Enumerable.Range(1, 20) + .Select(day => $"GenLauncherGO-2026-01-{day:00}-120000Z.log") + .ToArray(); + for (int index = 0; index < oldLogFileNames.Length; index++) + { + string logFilePath = Path.Combine(logDirectory, oldLogFileNames[index]); + File.WriteAllText(logFilePath, "old"); + File.SetLastWriteTimeUtc(logFilePath, DateTime.UtcNow.AddMinutes(-index - 1)); + } + + var services = new ServiceCollection(); + + services.AddGenLauncherGoLogging(logDirectory); + using (ServiceProvider provider = services.BuildServiceProvider()) + { + provider + .GetRequiredService>() + .LogInformation("Current session"); + } + + string[] retainedLogFileNames = Directory.GetFiles(logDirectory, "*.log") + .Select(file => Path.GetFileName(file)) + .ToArray(); + retainedLogFileNames.Should().HaveCount(14); + retainedLogFileNames.Should().Contain(oldLogFileNames.Take(13)); + retainedLogFileNames.Should().NotContain(oldLogFileNames.Skip(13)); + retainedLogFileNames.Except(oldLogFileNames).Should().ContainSingle(); + } + + /// + /// The retained logs are the most recent sessions, which the name of a log file does not decide: a restored + /// backup, a corrected clock, or a copied folder all leave names and ages disagreeing, and the sessions worth + /// keeping are still the ones that ran last. + /// + [Fact] + public void AddGenLauncherGoLogging_RetainsTheMostRecentlyWrittenLogs() + { + using TestDirectory directory = new(); + string logDirectory = directory.CreateDirectory("Logs"); + string[] logFileNamesOldestFirst = Enumerable.Range(1, 20) + .Select(day => $"GenLauncherGO-2026-01-{day:00}-120000Z.log") + .ToArray(); + for (int index = 0; index < logFileNamesOldestFirst.Length; index++) + { + string logFilePath = Path.Combine(logDirectory, logFileNamesOldestFirst[index]); + File.WriteAllText(logFilePath, "old"); + File.SetLastWriteTimeUtc( + logFilePath, + DateTime.UtcNow.AddMinutes(index - logFileNamesOldestFirst.Length)); + } + + var services = new ServiceCollection(); + + services.AddGenLauncherGoLogging(logDirectory); + using (ServiceProvider provider = services.BuildServiceProvider()) + { + provider + .GetRequiredService>() + .LogInformation("Current session"); + } + + string[] retainedLogFileNames = Directory.GetFiles(logDirectory, "*.log") + .Select(file => Path.GetFileName(file)) + .ToArray(); + retainedLogFileNames.Should().Contain(logFileNamesOldestFirst.Skip(7)); + retainedLogFileNames.Should().NotContain(logFileNamesOldestFirst.Take(7)); + } + + /// + /// The log folder is somewhere a user browses to and drops files. Pruning is scoped to the launcher's own + /// session logs by name, so the oldest thing in the folder is not automatically the next thing deleted. + /// + [Fact] + public void AddGenLauncherGoLogging_PrunesOnlyItsOwnSessionLogs() + { + using TestDirectory directory = new(); + string logDirectory = directory.CreateDirectory("Logs"); + string foreignFilePath = Path.Combine(logDirectory, "crash-report.txt"); + File.WriteAllText(foreignFilePath, "kept"); + File.SetLastWriteTimeUtc(foreignFilePath, DateTime.UtcNow.AddDays(-30)); + for (int day = 1; day <= 20; day++) + { + string logFilePath = Path.Combine(logDirectory, $"GenLauncherGO-2026-01-{day:00}-120000Z.log"); + File.WriteAllText(logFilePath, "old"); + File.SetLastWriteTimeUtc(logFilePath, DateTime.UtcNow.AddMinutes(-day)); + } + + var services = new ServiceCollection(); + + services.AddGenLauncherGoLogging(logDirectory); + using (ServiceProvider provider = services.BuildServiceProvider()) + { + provider + .GetRequiredService>() + .LogInformation("Current session"); + } + + File.ReadAllText(foreignFilePath).Should().Be("kept"); + } + + [Fact] + public void AddGenLauncherGoLogging_RedactsLocalPathsAndSensitiveQueryValues() + { + using TestDirectory directory = new(); + string logDirectory = directory.GetPath("Logs"); + var services = new ServiceCollection(); + const string SensitiveUrl = + "https://user:password@example.test/package?token=secret-value&X-Amz-Credential=aws-key" + + "&X-Amz-Signature=aws-signature&X-Amz-Security-Token=aws-token&name=safe"; + + services.AddGenLauncherGoLogging(logDirectory); + using (ServiceProvider provider = services.BuildServiceProvider()) + { + provider + .GetRequiredService>() + .LogError( + new InvalidOperationException(@"Failed under C:\Users\Alice\Secrets\file.txt"), + "Could not open {Path} from {Uri}.", + @"C:\Users\Alice\Secrets\file.txt", + SensitiveUrl); + } + + string logText = File.ReadAllText(Directory.GetFiles(logDirectory, "GenLauncherGO-*.log").Single()); + logText.Should().Contain("[local path]"); + logText.Should().Contain("https://[redacted]@example.test"); + logText.Should().Contain("token=[redacted]"); + logText.Should().Contain("X-Amz-Credential=[redacted]"); + logText.Should().Contain("X-Amz-Signature=[redacted]"); + logText.Should().Contain("X-Amz-Security-Token=[redacted]"); + logText.Should().NotContain("Alice"); + logText.Should().NotContain("password"); + logText.Should().NotContain("secret-value"); + logText.Should().NotContain("aws-key"); + logText.Should().NotContain("aws-signature"); + logText.Should().NotContain("aws-token"); + } + + [Fact] + public void AddGenLauncherGoLogging_RedactsUncAndForwardSlashWindowsPaths() + { + using TestDirectory directory = new(); + string logDirectory = directory.GetPath("Logs"); + var services = new ServiceCollection(); + + services.AddGenLauncherGoLogging(logDirectory); + using (ServiceProvider provider = services.BuildServiceProvider()) + { + provider + .GetRequiredService>() + .LogWarning( + "Could not read {ForwardSlashPath} or {UncPath}.", + "C:/Users/Alice Example/Secrets/file.txt", + @"\\fileserver\profiles\Bob Example\Secrets\file.txt"); + } + + string logText = File.ReadAllText(Directory.GetFiles(logDirectory, "GenLauncherGO-*.log").Single()); + logText.Should().Contain("[local path]"); + logText.Should().NotContain("Alice Example"); + logText.Should().NotContain("Bob Example"); + logText.Should().NotContain("fileserver"); + } + + [GeneratedRegex(@"^GenLauncherGO-\d{4}-\d{2}-\d{2}-\d{6}Z(-\d+)?\.log$")] + private static partial Regex SessionLogFileNamePattern(); +} diff --git a/GenLauncherGO.Tests/Infrastructure/Logging/SensitiveDataRedactingTextFormatterTests.cs b/GenLauncherGO.Tests/Infrastructure/Logging/SensitiveDataRedactingTextFormatterTests.cs new file mode 100644 index 00000000..ec10c872 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Logging/SensitiveDataRedactingTextFormatterTests.cs @@ -0,0 +1,260 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using GenLauncherGO.Infrastructure.Logging; +using Serilog.Events; +using Serilog.Parsing; + +namespace GenLauncherGO.Tests.Infrastructure.Logging; + +/// +/// The formatter is the last step before a log event reaches a file a user attaches to a bug report, so each test +/// asserts both that the private value is gone and that the diagnostics around it survived. +/// +public sealed class SensitiveDataRedactingTextFormatterTests +{ + private static readonly MessageTemplateParser _messageTemplateParser = new(); + + private static readonly DateTimeOffset _eventTimestamp = + new(2026, 1, 2, 3, 4, 5, 678, TimeSpan.FromHours(2)); + + /// + /// A user's folder names carry their real name and their installed software, so no absolute local path may + /// survive in any of the spellings Windows accepts. + /// + [Theory] + [InlineData(@"C:\Users\Alice Example\Secrets\file.txt", "Alice Example")] + [InlineData("C:/Users/Alice Example/Secrets/file.txt", "Alice Example")] + [InlineData(@"d:\Games\Alice Example\game.dat", "Alice Example")] + [InlineData(@"\\fileserver\profiles\Alice Example\file.txt", "fileserver")] + public void Format_AbsoluteLocalPath_ReplacesPathAndKeepsSurroundingMessage( + string path, + string privateSegment) + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent("Could not read {Path} for the active profile.", ("Path", path)); + + formatter.Format(logEvent, output); + + string text = output.ToString(); + text.Should().Contain("[local path]"); + text.Should().Contain("for the active profile."); + text.Should().NotContain(privateSegment); + } + + /// + /// Redaction has to stop at absolute local paths: a message that carries none must reach the log intact, or the + /// log stops being useful for support. + /// + [Fact] + public void Format_MessageWithoutLocalPath_WritesTheRenderedMessageUnchanged() + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent( + "Installed {Count} packages from {RelativePath}.", + ("Count", 3), + ("RelativePath", "Data/INI/GameData.ini")); + + formatter.Format(logEvent, output); + + string text = output.ToString(); + text.Should().Contain("Installed 3 packages"); + text.Should().Contain("Data/INI/GameData.ini"); + } + + /// + /// A download URL is logged whenever a transfer fails, and its user-info segment is a live credential. The host + /// has to stay so the failing mirror is still identifiable. + /// + [Fact] + public void Format_UriUserInfo_ReplacesCredentialsAndKeepsTheHost() + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent( + "Download from {Uri} failed.", + ("Uri", "https://alice:s3cret-value@packages.example.test/mod.zip")); + + formatter.Format(logEvent, output); + + string text = output.ToString(); + text.Should().Contain("https://[redacted]@packages.example.test/mod.zip"); + text.Should().NotContain("s3cret-value"); + text.Should().NotContain("alice"); + } + + /// + /// Presigned S3 links and OAuth callbacks put live credentials in the query string. The parameter name must + /// survive so a reader can see which credential was in play, and unrelated parameters must survive with it. + /// + [Theory] + [InlineData("access_token")] + [InlineData("access-token")] + [InlineData("accesstoken")] + [InlineData("api_key")] + [InlineData("apikey")] + [InlineData("credential")] + [InlineData("secret")] + [InlineData("token")] + [InlineData("session_token")] + [InlineData("security_token")] + [InlineData("password")] + [InlineData("signature")] + [InlineData("sig")] + [InlineData("X-Amz-Credential")] + [InlineData("X-Amz-Signature")] + [InlineData("X-Amz-Security-Token")] + public void Format_SensitiveQueryParameter_ReplacesValueAndKeepsKeyAndSafeParameters(string parameterName) + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent( + "Requested {Uri}.", + ("Uri", $"https://packages.example.test/mod.zip?{parameterName}=s3cret-value&name=safe")); + + formatter.Format(logEvent, output); + + string text = output.ToString(); + text.Should().Contain($"{parameterName}=[redacted]"); + text.Should().Contain("name=safe"); + text.Should().NotContain("s3cret-value"); + } + + /// + /// An exception is the payload of most bug reports, so its type and message have to reach the log on their own + /// line even though the paths inside them do not. + /// + [Fact] + public void Format_EventWithException_WritesRedactedExceptionBelowTheMessage() + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent( + "Launch preparation failed.", + new InvalidOperationException(@"Could not stage C:\Users\Alice Example\Deploy")); + + formatter.Format(logEvent, output); + + string[] lines = ReadLines(output); + lines.Should().HaveCount(2); + lines[0].Should().EndWith("Launch preparation failed."); + lines[1].Should().Contain(nameof(InvalidOperationException)); + lines[1].Should().Contain("[local path]"); + lines[1].Should().NotContain("Alice Example"); + } + + [Fact] + public void Format_EventWithoutException_WritesOnlyTheMessageLine() + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent("Launch preparation succeeded."); + + formatter.Format(logEvent, output); + + ReadLines(output).Should().ContainSingle() + .Which.Should().EndWith("Launch preparation succeeded."); + } + + /// + /// A stack frame names the source file on the machine that built the launcher, which is a private path like any + /// other. The line number is the part that makes the frame worth logging, so it has to outlive the path. + /// + [Fact] + public void Format_StackFrameSourcePath_ReplacesPathAndKeepsLineNumber() + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent( + @" at GenLauncherGO.Launch() in C:\build\Alice Example\Launcher.cs:line 4711"); + + formatter.Format(logEvent, output); + + string text = output.ToString(); + text.Should().Contain("[local source]:line 4711"); + text.Should().NotContain("Alice Example"); + } + + /// + /// Log files are read in bulk and filtered by level, so every level has to reach the file as its own marker. + /// + [Theory] + [InlineData(LogEventLevel.Verbose, "VRB")] + [InlineData(LogEventLevel.Debug, "DBG")] + [InlineData(LogEventLevel.Information, "INF")] + [InlineData(LogEventLevel.Warning, "WRN")] + [InlineData(LogEventLevel.Error, "ERR")] + [InlineData(LogEventLevel.Fatal, "FTL")] + public void Format_EachLevel_WritesItsOwnLevelMarker(LogEventLevel level, string expectedMarker) + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent("Ready.", level); + + formatter.Format(logEvent, output); + + output.ToString().Should().Contain($"[{expectedMarker}] Ready."); + } + + /// + /// Support reads these files beside Windows event logs, so each line has to carry a sortable timestamp whose + /// offset makes it comparable with events recorded in another time zone. + /// + [Fact] + public void Format_Timestamp_WritesTheEventTimestampWithItsOffset() + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent("Ready."); + + formatter.Format(logEvent, output); + + string timestampText = ReadLines(output)[0].Split(" [", StringSplitOptions.None)[0]; + DateTimeOffset.ParseExact( + timestampText, + "yyyy-MM-dd HH:mm:ss.fff zzz", + CultureInfo.InvariantCulture) + .Should().Be(_eventTimestamp); + } + + private static string[] ReadLines(StringWriter output) + { + return output.ToString().Split( + Environment.NewLine, + StringSplitOptions.RemoveEmptyEntries); + } + + private static LogEvent CreateLogEvent( + string messageTemplate, + params (string Name, object? Value)[] properties) + { + return CreateLogEvent(messageTemplate, LogEventLevel.Information, null, properties); + } + + private static LogEvent CreateLogEvent(string messageTemplate, LogEventLevel level) + { + return CreateLogEvent(messageTemplate, level, null); + } + + private static LogEvent CreateLogEvent(string messageTemplate, Exception exception) + { + return CreateLogEvent(messageTemplate, LogEventLevel.Information, exception); + } + + private static LogEvent CreateLogEvent( + string messageTemplate, + LogEventLevel level, + Exception? exception, + params (string Name, object? Value)[] properties) + { + return new LogEvent( + _eventTimestamp, + level, + exception, + _messageTemplateParser.Parse(messageTemplate), + properties.Select(property => new LogEventProperty(property.Name, new ScalarValue(property.Value)))); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs new file mode 100644 index 00000000..6287f7d8 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs @@ -0,0 +1,437 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class FileSystemLocalLauncherContentServiceTests +{ + [Fact] + public void FindInstalledVersions_ReturnsInstalledModsPatchesAndAddons() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + Directory.CreateDirectory(Path.Combine(paths.ModsDirectory, "ShockWave", "1.2", "Data", "Empty")); + CreateFile(Path.Combine(paths.ModsDirectory, "ShockWave", "1.2", "Data", "Real", "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")); + + IReadOnlyList versions = service.FindInstalledVersions(paths); + + versions.Should().HaveCount(3); + versions.Should().ContainSingle(version => + version.ModificationType == ModificationType.Mod && + version.Name == "ShockWave" && + version.Version == "1.2" && + version.Installation.Installed); + versions.Should().ContainSingle(version => + version.ModificationType == ModificationType.Addon && + version.Name == "HD" && + version.Version == "1.0" && + version.ParentContentName == "ShockWave" && + version.Installation.Installed); + versions.Should().ContainSingle(version => + version.ModificationType == ModificationType.Patch && + version.Name == "Balance" && + version.Version == "2.0" && + version.ParentContentName == "ShockWave" && + version.Installation.Installed); + versions.Should().NotContain(version => version.Name == "EmptyMod"); + } + + /// + /// A linked Mods tree is refused outright rather than walked, because the versions it would report are folders + /// the launcher does not own and would later delete on the user's behalf. + /// + [Fact] + public void FindInstalledVersions_RefusesModsTreeContainingLinkedDirectory() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + FileSystemLocalLauncherContentService service = CreateService(); + CreateFile(Path.Combine(paths.ModsDirectory, "ShockWave", "1.2", "Data", "INI.big")); + string outsideDirectory = directory.CreateDirectory("OutsideMods"); + string outsideVersionFile = directory.CreateFile("OutsideMods/1.0/Outside.big", "outside"); + ReparsePointTestSupport.CreateDirectoryJunction( + Path.Combine(paths.ModsDirectory, "Linked"), + outsideDirectory); + + Action act = () => service.FindInstalledVersions(paths); + + act.Should().Throw(); + File.ReadAllText(outsideVersionFile).Should().Be("outside"); + } + + /// + /// A launcher-owned data root that is itself a link resolves to a tree the launcher never created, and every + /// version reported from it would later be deleted on the user's behalf, so the scan is refused before it + /// walks a single folder. + /// + [Fact] + public void FindInstalledVersions_RefusesModsTreeReachedThroughALinkedAncestor() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + FileSystemLocalLauncherContentService service = CreateService(); + Directory.Delete(paths.OwnedGameDataDirectory, true); + string outsideDirectory = directory.CreateDirectory("OutsideData"); + string outsideVersionFile = directory.CreateFile("OutsideData/Mods/ShockWave/1.2/Outside.big", "outside"); + ReparsePointTestSupport.CreateDirectoryJunction(paths.OwnedGameDataDirectory, outsideDirectory); + + Action act = () => service.FindInstalledVersions(paths); + + act.Should().Throw(); + File.ReadAllText(outsideVersionFile).Should().Be("outside"); + } + + [Fact] + public void FindInstalledVersions_ReturnsNothingWhenTheModsFolderHasNotBeenCreated() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + + IReadOnlyList versions = service.FindInstalledVersions(paths); + + versions.Should().BeEmpty(); + } + + /// + /// An empty version folder is a leftover, not an installation. That holds for a patch or add-on version folder + /// exactly as it does for a modification's own, or the launcher would offer to launch content with no files. + /// + [Fact] + public void FindInstalledVersions_IgnoresEmptyChildVersionDirectories() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + CreateFile(Path.Combine(paths.ModsDirectory, "ShockWave", "1.2", "INI.big")); + Directory.CreateDirectory(Path.Combine(paths.ModsDirectory, "ShockWave", "Addons", "HD", "1.0")); + Directory.CreateDirectory(Path.Combine(paths.ModsDirectory, "ShockWave", "Patches", "Balance", "2.0")); + + IReadOnlyList versions = service.FindInstalledVersions(paths); + + versions.Should().ContainSingle().Which.Name.Should().Be("ShockWave"); + } + + [Fact] + public void DeleteVersion_DeletesVersionAndPrunesEmptyParents() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(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 LauncherContentVersion + { + ModificationType = ModificationType.Addon, + Name = "HD", + Version = "1.0", + ParentContentName = "ShockWave" + }; + + service.DeleteVersion(paths, version.ContentKey); + + Directory.Exists(versionDirectory).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.ModsDirectory, "ShockWave")).Should().BeFalse(); + Directory.Exists(paths.ModsDirectory).Should().BeTrue(); + } + + [Fact] + public void DeleteVersion_DeletesPackageStagingAndRecoveryFoldersWhenInstalledFolderIsMissing() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string versionDirectory = Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"); + var installedPath = new OwnedContentPath(paths.ModsDirectory, versionDirectory); + string packageStagingDirectory = paths.GetPackageTemporaryPath(installedPath).FullPath; + OwnedContentPath packageBackupPath = paths.GetPackageBackupPath(installedPath); + CreateFile(Path.Combine(packageStagingDirectory, "Data", "INI.big")); + CreateFile(Path.Combine(packageBackupPath.FullPath, "Data", "OldINI.big")); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + + service.DeleteVersion(paths, version.ContentKey); + + Directory.Exists(packageStagingDirectory).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.TempDirectory, "Packages", "ShockWave")).Should().BeFalse(); + Directory.Exists(packageBackupPath.FullPath).Should().BeFalse(); + Directory.Exists(Path.Combine(packageBackupPath.OwnerRoot, "ShockWave")).Should().BeFalse(); + Directory.Exists(packageBackupPath.OwnerRoot).Should().BeFalse(); + Directory.Exists(versionDirectory).Should().BeFalse(); + } + + [Fact] + public void DeleteVersion_DeletesPackageStagingFolderForChildContentWhenInstalledFolderIsMissing() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string versionDirectory = Path.Combine(paths.ModsDirectory, "ShockWave", "Addons", "HD", "1.0"); + string packageStagingDirectory = paths.GetPackageTemporaryPath( + new OwnedContentPath(paths.ModsDirectory, versionDirectory)).FullPath; + CreateFile(Path.Combine(packageStagingDirectory, "HD.big")); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Addon, + Name = "HD", + Version = "1.0", + ParentContentName = "ShockWave" + }; + + service.DeleteVersion(paths, version.ContentKey); + + Directory.Exists(packageStagingDirectory).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.TempDirectory, "Packages", "ShockWave")).Should().BeFalse(); + Directory.Exists(versionDirectory).Should().BeFalse(); + } + + [Fact] + public void DeleteContent_DeletesModRootAndPackageStagingAndRecoveryRoots() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string contentDirectory = Path.Combine(paths.ModsDirectory, "ShockWave"); + var installedPath = new OwnedContentPath(paths.ModsDirectory, contentDirectory); + string packageStagingDirectory = paths.GetPackageTemporaryPath(installedPath).FullPath; + OwnedContentPath packageBackupPath = paths.GetPackageBackupPath(installedPath); + CreateFile(Path.Combine(contentDirectory, "1.2", "Data", "INI.big")); + CreateFile(Path.Combine(contentDirectory, "Addons", "HD", "1.0", "HD.big")); + CreateFile(Path.Combine(packageStagingDirectory, "1.2", "Data", "INI.big")); + CreateFile(Path.Combine(packageBackupPath.FullPath, "1.2", "Data", "OldINI.big")); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + + service.DeleteContent(paths, version.ContentKey); + + Directory.Exists(contentDirectory).Should().BeFalse(); + Directory.Exists(packageStagingDirectory).Should().BeFalse(); + Directory.Exists(packageBackupPath.FullPath).Should().BeFalse(); + Directory.Exists(packageBackupPath.OwnerRoot).Should().BeFalse(); + Directory.Exists(paths.ModsDirectory).Should().BeTrue(); + } + + [Fact] + public void DeleteContent_DeletesChildContentRoot() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string contentDirectory = Path.Combine(paths.ModsDirectory, "ShockWave", "Addons", "HD"); + CreateFile(Path.Combine(contentDirectory, "1.0", "HD.big")); + CreateFile(Path.Combine(contentDirectory, "2.0", "HD.big")); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Addon, + Name = "HD", + Version = "1.0", + ParentContentName = "ShockWave" + }; + + service.DeleteContent(paths, version.ContentKey); + + Directory.Exists(contentDirectory).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.ModsDirectory, "ShockWave")).Should().BeFalse(); + } + + [Fact] + public void DeleteEmptyPackageBackupDirectories_RemovesOnlyEmptyRecoveryDirectories() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string emptyBackupDirectory = Path.Combine(paths.PackageBackupsDirectory, "Unused", "1.0"); + string retainedBackupFile = Path.Combine(paths.PackageBackupsDirectory, "Active", "1.0", "asset.big"); + Directory.CreateDirectory(emptyBackupDirectory); + CreateFile(retainedBackupFile); + + service.DeleteEmptyPackageBackupDirectories(paths); + + Directory.Exists(emptyBackupDirectory).Should().BeFalse(); + File.Exists(retainedBackupFile).Should().BeTrue(); + Directory.Exists(paths.PackageBackupsDirectory).Should().BeTrue(); + } + + /// + /// Removing a version the launcher does not have installed deletes nothing, so it must not prune folders as a + /// side effect either: the tree is only tidied after something was actually removed from it. + /// + [Fact] + public void DeleteVersion_LeavesTheContentFolderWhenTheVersionIsNotInstalled() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string contentDirectory = Path.Combine(paths.ModsDirectory, "ShockWave"); + Directory.CreateDirectory(contentDirectory); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + + service.DeleteVersion(paths, version.ContentKey); + + Directory.Exists(contentDirectory).Should().BeTrue(); + } + + [Fact] + public void DeleteVersion_RefusesPathOutsideModsRoot() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "..", + Version = "Outside" + }; + + Action act = () => service.DeleteVersion(paths, version.ContentKey); + + act.Should().Throw(); + } + + [Fact] + public void DeleteImagesIfUnused_DeletesOwnedCacheFolderWhenNoCardReferencesContentName() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + string cardImage = Path.Combine(imageDirectory, "1.2.png"); + string backgroundImage = Path.Combine( + imageDirectory, + LauncherContentTheme.ResolveBackgroundImageBaseName("1.2") + ".jpg"); + string cachedTheme = Path.Combine( + imageDirectory, + LauncherContentTheme.ResolveCacheBaseName("1.2") + ".yaml"); + string otherImage = Path.Combine(imageDirectory, "readme.txt"); + CreateFile(cardImage); + CreateFile(backgroundImage); + CreateFile(cachedTheme); + CreateFile(otherImage); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + + service.DeleteImagesIfUnused(paths, version.ContentKey, new LauncherData()); + + File.Exists(cardImage).Should().BeFalse(); + File.Exists(backgroundImage).Should().BeFalse(); + File.Exists(cachedTheme).Should().BeFalse(); + File.Exists(otherImage).Should().BeFalse(); + Directory.Exists(imageDirectory).Should().BeFalse(); + } + + [Fact] + public void DeleteImagesIfUnused_AdvertisingUsesTheCatalogCacheName() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string imageDirectory = paths.GetModificationImagesDirectory("Do you like GenLauncher"); + string imagePath = Path.Combine(imageDirectory, "0.jpg"); + CreateFile(imagePath); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Advertising, + Name = "Do you like GenLauncher?", + Version = "1.0" + }; + + service.DeleteImagesIfUnused(paths, version.ContentKey, new LauncherData()); + + Directory.Exists(imageDirectory).Should().BeFalse(); + } + + /// + /// Someone can point a modification's image cache at a folder of their own. Removing the content card must + /// still clear the cache entry, and must do it by unlinking rather than by deleting what is on the far side. + /// + [Fact] + public void DeleteImagesIfUnused_RemovesLinkedImageCacheWithoutDeletingItsTarget() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + FileSystemLocalLauncherContentService service = CreateService(); + string outsideDirectory = directory.CreateDirectory("OutsideImages"); + string firstOutsideImage = directory.CreateFile("OutsideImages/1.2.png", "first"); + string secondOutsideImage = directory.CreateFile("OutsideImages/holiday.png", "second"); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + ReparsePointTestSupport.CreateDirectoryJunction(imageDirectory, outsideDirectory); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + + service.DeleteImagesIfUnused(paths, version.ContentKey, new LauncherData()); + + Directory.Exists(imageDirectory).Should().BeFalse(); + File.ReadAllText(firstOutsideImage).Should().Be("first"); + File.ReadAllText(secondOutsideImage).Should().Be("second"); + } + + [Fact] + public void DeleteImagesIfUnused_KeepsImagesWhenCardStillReferencesContentName() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string imagePath = Path.Combine(paths.GetModificationImagesDirectory("ShockWave"), "1.2.png"); + CreateFile(imagePath); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0" + }); + + service.DeleteImagesIfUnused(paths, version.ContentKey, launcherData); + + File.Exists(imagePath).Should().BeTrue(); + } + + private static FileSystemLocalLauncherContentService CreateService() + { + return new FileSystemLocalLauncherContentService( + NullLogger.Instance); + } + + 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..b162e59d --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemManualModificationImporterTests.cs @@ -0,0 +1,260 @@ +using System; +using System.IO; +using System.Threading; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Archives.Contracts; +using GenLauncherGO.Infrastructure.Mods.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class FileSystemManualModificationImporterTests +{ + [Fact] + public void Import_CopiesRegularFilesToDestination() + { + 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(); + + importer.Import(new[] { sourceFilePath }, CreateOwnedDestination(directory.Path, destinationDirectory), TestContext.Current.CancellationToken); + + File.ReadAllText(Path.Combine(destinationDirectory, "readme.txt")) + .Should().Be("manual content"); + File.Exists(sourceFilePath).Should().BeTrue(); + } + + [Fact] + public void Import_RenamesLooseBigFilesToGibFiles() + { + 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(); + + importer.Import(new[] { sourceFilePath }, CreateOwnedDestination(directory.Path, destinationDirectory), TestContext.Current.CancellationToken); + + File.Exists(Path.Combine(destinationDirectory, "package.big")).Should().BeFalse(); + File.ReadAllText(Path.Combine(destinationDirectory, "package.gib")) + .Should().Be("big content"); + } + + [Fact] + public void Import_CopiesLooseGibFilesToDestination() + { + 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.gib"); + File.WriteAllText(sourceFilePath, "gib content"); + + FileSystemManualModificationImporter importer = CreateImporter(); + + importer.Import(new[] { sourceFilePath }, CreateOwnedDestination(directory.Path, destinationDirectory), TestContext.Current.CancellationToken); + + File.ReadAllText(Path.Combine(destinationDirectory, "package.gib")) + .Should().Be("gib content"); + File.Exists(sourceFilePath).Should().BeTrue(); + } + + /// + /// Re-importing over content that is already in place must not fail the whole selection, so a file that is + /// already present is left exactly as it is rather than copied over. + /// + [Fact] + public void Import_DoesNotFailWhenDestinationFileAlreadyExists() + { + using var directory = new TestDirectory(); + string sourceFilePath = directory.CreateFile("source/readme.txt", "manual content"); + string destinationDirectory = directory.CreateDirectory("destination"); + string destinationFilePath = directory.CreateFile("destination/readme.txt", "already imported"); + FileSystemManualModificationImporter importer = CreateImporter(); + + importer.Import(new[] { sourceFilePath }, CreateOwnedDestination(directory.Path, destinationDirectory), TestContext.Current.CancellationToken); + + File.ReadAllText(destinationFilePath).Should().Be("already imported"); + } + + [Theory] + [InlineData(".zip")] + [InlineData(".rar")] + [InlineData(".7z")] + public void Import_ExtractsArchivesAndDeletesStagedArchive(string extension) + { + using var directory = new TestDirectory(); + string sourceDirectory = Path.Combine(directory.Path, "source"); + string destinationDirectory = Path.Combine(directory.Path, "destination"); + Directory.CreateDirectory(sourceDirectory); + string archiveFileName = "package" + extension; + string sourceFilePath = Path.Combine(sourceDirectory, archiveFileName); + File.WriteAllText(sourceFilePath, "archive content"); + + RecordingArchiveExtractor archiveExtractor = new() { ExtractHandler = WriteExtractedFile }; + FileSystemManualModificationImporter importer = CreateImporter(archiveExtractor); + + importer.Import(new[] { sourceFilePath }, CreateOwnedDestination(directory.Path, destinationDirectory), TestContext.Current.CancellationToken); + + archiveExtractor.ArchiveFilePath.Should().Be(Path.Combine(destinationDirectory, archiveFileName)); + archiveExtractor.DestinationDirectory.Should().Be(destinationDirectory); + File.Exists(Path.Combine(destinationDirectory, archiveFileName)).Should().BeFalse(); + File.ReadAllText(Path.Combine(destinationDirectory, "extracted.txt")) + .Should().Be("extracted content"); + File.Exists(sourceFilePath).Should().BeTrue(); + } + + /// + /// A cancelled import is the user's own decision, so it stops at the next file and is never reported as a + /// failure. + /// + [Fact] + public void Import_StopsAtCancellationWithoutImportingRemainingFiles() + { + using var directory = new TestDirectory(); + string archiveFilePath = directory.CreateFile("source/package.zip", "archive content"); + string remainingFilePath = directory.CreateFile("source/readme.txt", "manual content"); + string destinationDirectory = Path.Combine(directory.Path, "destination"); + using CancellationTokenSource cancellation = new(); + RecordingArchiveExtractor archiveExtractor = new() { ExtractHandler = _ => cancellation.Cancel() }; + RecordingLogger logger = new(); + FileSystemManualModificationImporter importer = CreateImporter(archiveExtractor, logger); + + Action act = () => importer.Import( + new[] { archiveFilePath, remainingFilePath }, + CreateOwnedDestination(directory.Path, destinationDirectory), + cancellation.Token); + + act.Should().Throw(); + File.Exists(Path.Combine(destinationDirectory, "readme.txt")).Should().BeFalse(); + logger.Entries.Should().NotContain(entry => entry.LogLevel == LogLevel.Error); + } + + [Fact] + public void Import_RejectsEmptySourceFileList() + { + using var directory = new TestDirectory(); + FileSystemManualModificationImporter importer = CreateImporter(); + string destinationDirectory = Path.Combine(directory.Path, "destination"); + + Action act = () => importer.Import( + Array.Empty(), + CreateOwnedDestination(directory.Path, destinationDirectory)); + + act.Should().Throw() + .WithMessage("*At least one source file is required*"); + } + + /// + /// Import copies each selected file under its own name. A path that names no file has no name to copy under, + /// and would otherwise resolve onto the destination folder itself. + /// + [Fact] + public void Import_RejectsSourcePathThatNamesNoFile() + { + using var directory = new TestDirectory(); + string sourceDirectory = directory.CreateDirectory("source"); + string destinationDirectory = Path.Combine(directory.Path, "destination"); + FileSystemManualModificationImporter importer = CreateImporter(); + + Action act = () => importer.Import( + new[] { sourceDirectory + Path.DirectorySeparatorChar }, + CreateOwnedDestination(directory.Path, destinationDirectory)); + + act.Should().Throw() + .Which.ParamName.Should().Be("sourceFilePath"); + } + + [Fact] + public void ImportRequest_RejectsDestinationOutsideOwnershipBoundaryBeforeMutation() + { + using var directory = new TestDirectory(); + string sourceFilePath = directory.CreateFile("source/readme.txt", "manual content"); + string ownedRoot = directory.CreateDirectory("owned"); + string outsideDestination = Path.Combine(directory.Path, "outside", "1.0"); + FileSystemManualModificationImporter importer = CreateImporter(); + + Action act = () => importer.Import( + new[] { sourceFilePath }, + new OwnedContentPath(ownedRoot, outsideDestination)); + + act.Should().Throw() + .WithMessage("*below its owning root*"); + Directory.Exists(outsideDestination).Should().BeFalse(); + } + + [Fact] + public void Import_RejectsReparsePointsInDestinationBeforeArchiveExtraction() + { + using var directory = new TestDirectory(); + string sourceFilePath = directory.CreateFile("source/package.zip", "archive content"); + string ownedRoot = directory.CreateDirectory("owned"); + string destinationDirectory = directory.CreateDirectory("owned/Mod/1.0"); + string externalTarget = directory.CreateDirectory("external"); + string externalFile = directory.CreateFile("external/target.txt", "target"); + ReparsePointTestSupport.CreateDirectoryJunction( + Path.Combine(destinationDirectory, "linked"), + externalTarget); + RecordingArchiveExtractor archiveExtractor = new(); + FileSystemManualModificationImporter importer = CreateImporter(archiveExtractor); + + Action act = () => importer.Import( + new[] { sourceFilePath }, + CreateOwnedDestination(ownedRoot, destinationDirectory)); + + act.Should().Throw() + .WithMessage("*reparse point*"); + archiveExtractor.ArchiveFilePath.Should().BeNull(); + File.Exists(Path.Combine(destinationDirectory, "package.zip")).Should().BeFalse(); + File.ReadAllText(externalFile).Should().Be("target"); + } + + [Fact] + public void Import_RethrowsWhenSourceFileIsMissing() + { + using var directory = new TestDirectory(); + string missingSourceFilePath = Path.Combine(directory.Path, "missing.gib"); + string destinationDirectory = Path.Combine(directory.Path, "destination"); + RecordingLogger logger = new(); + FileSystemManualModificationImporter importer = CreateImporter(logger: logger); + + Action act = () => importer.Import( + new[] { missingSourceFilePath }, + CreateOwnedDestination(directory.Path, destinationDirectory)); + + act.Should().Throw(); + logger.Entries.Should().Contain(entry => + entry.LogLevel == LogLevel.Error && + entry.Exception is FileNotFoundException); + } + + private static void WriteExtractedFile(string destinationDirectory) + { + File.WriteAllText(Path.Combine(destinationDirectory, "extracted.txt"), "extracted content"); + } + + private static OwnedContentPath CreateOwnedDestination( + string ownedRoot, + string destinationDirectory) + { + return new OwnedContentPath(ownedRoot, destinationDirectory); + } + + private static FileSystemManualModificationImporter CreateImporter( + IArchiveExtractor? archiveExtractor = null, + ILogger? logger = null) + { + return new FileSystemManualModificationImporter( + archiveExtractor ?? new RecordingArchiveExtractor(), + logger ?? NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs new file mode 100644 index 00000000..a0541a96 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs @@ -0,0 +1,549 @@ +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 Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class FileSystemModificationImageFileServiceTests +{ + [Fact] + public void FindExistingImageFilePath_FindsCachedImageWhateverItsExtension() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string imagePath = Path.Combine(imageDirectory, "1.2.jpg"); + File.WriteAllText(imagePath, "image"); + File.WriteAllText(Path.Combine(imageDirectory, "2.0.png"), "other image"); + FileSystemModificationImageFileService service = CreateService(paths); + + string? existingImagePath = service.FindExistingImageFilePath(ModificationType.Mod, "ShockWave", "1.2"); + + existingImagePath.Should().Be(imagePath); + } + + /// + /// Characterizes today's behaviour, which is not the behaviour anyone wants: the cache lookup builds a + /// <version>.* wildcard, so version "1.2" also matches the artwork cached for "1.2.5". Revisit + /// with PR-11, which replaces the wildcard with an exact base-name match. + /// + [Fact] + public void FindExistingImageFilePath_MatchesTheImageOfAVersionThatOnlyExtendsTheRequestedName() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string siblingImagePath = Path.Combine(imageDirectory, "1.2.5.png"); + File.WriteAllText(siblingImagePath, "sibling"); + FileSystemModificationImageFileService service = CreateService(paths); + + string? existingImagePath = service.FindExistingImageFilePath(ModificationType.Mod, "ShockWave", "1.2"); + + existingImagePath.Should().Be(siblingImagePath); + } + + /// + /// Characterizes today's behaviour: the same <version>.* wildcard makes removing "1.2" take + /// "1.2.5" with it. Revisit with PR-11. + /// + [Fact] + public void TryDeleteImage_AlsoRemovesTheImageOfAVersionThatOnlyExtendsTheRequestedName() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string requestedImagePath = Path.Combine(imageDirectory, "1.2.png"); + string siblingImagePath = Path.Combine(imageDirectory, "1.2.5.png"); + File.WriteAllText(requestedImagePath, "requested"); + File.WriteAllText(siblingImagePath, "sibling"); + FileSystemModificationImageFileService service = CreateService(paths); + + bool deleted = service.TryDeleteImage(ModificationType.Mod, "ShockWave", "1.2"); + + deleted.Should().BeTrue(); + File.Exists(requestedImagePath).Should().BeFalse(); + File.Exists(siblingImagePath).Should().BeFalse(); + } + + /// + /// The wildcard is anchored on the whole requested name, so the over-match only ever runs one way: removing + /// "1.2" may take "1.2.5" with it, but it must never take the artwork of "1", which is a different version the + /// user still has installed. + /// + [Fact] + public void TryDeleteImage_KeepsTheImageOfTheVersionWhoseNameTheRequestedNameExtends() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string requestedImagePath = Path.Combine(imageDirectory, "1.2.png"); + string shorterVersionImagePath = Path.Combine(imageDirectory, "1.png"); + File.WriteAllText(requestedImagePath, "requested"); + File.WriteAllText(shorterVersionImagePath, "shorter"); + FileSystemModificationImageFileService service = CreateService(paths); + + bool deleted = service.TryDeleteImage(ModificationType.Mod, "ShockWave", "1.2"); + + deleted.Should().BeTrue(); + File.Exists(requestedImagePath).Should().BeFalse(); + File.ReadAllText(shorterVersionImagePath).Should().Be("shorter"); + } + + [Fact] + public void FindExistingImageFilePath_ReturnsNullWhenNoCachedImageMatches() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + File.WriteAllText(Path.Combine(imageDirectory, "1.2.png"), "image"); + FileSystemModificationImageFileService service = CreateService(paths); + + string? existingImagePath = service.FindExistingImageFilePath(ModificationType.Mod, "ShockWave", "3.0"); + + existingImagePath.Should().BeNull(); + } + + /// + /// Characterizes today's behaviour: the stale-sibling sweep before a replacement uses the same + /// <version>.* wildcard, so replacing "1.2" also discards the artwork of "1.2.5". Revisit with + /// PR-11. + /// + [Fact] + public async Task ReplaceImageAsync_AlsoRemovesTheImageOfAVersionThatOnlyExtendsTheRequestedNameAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string siblingImagePath = Path.Combine(imageDirectory, "1.2.5.png"); + await File.WriteAllTextAsync(siblingImagePath, "sibling", TestContext.Current.CancellationToken); + string sourceImagePath = directory.CreateFile("selected.png", "new"); + FileSystemModificationImageFileService service = CreateService(paths); + + string destinationPath = await service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + destinationPath.Should().Be(Path.Combine(imageDirectory, "1.2.png")); + File.Exists(siblingImagePath).Should().BeFalse(); + (await File.ReadAllTextAsync(destinationPath, TestContext.Current.CancellationToken)).Should().Be("new"); + } + + [Fact] + public void FindExistingImageFilePath_ReturnsNullForMissingDirectory() + { + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + string? existingImagePath = service.FindExistingImageFilePath(ModificationType.Mod, "Missing", "1.2"); + + existingImagePath.Should().BeNull(); + } + + [Fact] + public void CountImageFiles_ReturnsZeroForMissingDirectory() + { + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + int count = service.CountImageFiles(ModificationType.Mod, "Missing"); + + count.Should().Be(0); + } + + [Fact] + public void CountImageFiles_ReturnsImageFileCount() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + File.WriteAllText(Path.Combine(imageDirectory, "1.0.png"), "image"); + File.WriteAllText(Path.Combine(imageDirectory, "1.1.jpg"), "image"); + FileSystemModificationImageFileService service = CreateService(paths); + + int count = service.CountImageFiles(ModificationType.Mod, "ShockWave"); + + count.Should().Be(2); + } + + [Fact] + public void CountImageFiles_AdvertisingUsesTheCatalogCacheName() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("Do you like GenLauncher"); + Directory.CreateDirectory(imageDirectory); + File.WriteAllText(Path.Combine(imageDirectory, "0.jpg"), "image"); + FileSystemModificationImageFileService service = CreateService(paths); + + int count = service.CountImageFiles( + ModificationType.Advertising, + "Do you like GenLauncher?"); + + count.Should().Be(1); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ImageExists_ReturnsFalseForMissingPathValues(string? imagePath) + { + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + bool exists = service.ImageExists(imagePath); + + exists.Should().BeFalse(); + } + + [Fact] + public void ImageExists_ReturnsTrueForExistingFile() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imagePath = paths.GetModificationImageFilePath("ShockWave", "1.2.png"); + Directory.CreateDirectory(Path.GetDirectoryName(imagePath)!); + File.WriteAllText(imagePath, "image"); + FileSystemModificationImageFileService service = CreateService(paths); + + bool exists = service.ImageExists(imagePath); + + exists.Should().BeTrue(); + } + + [Fact] + public void ImageExists_ReturnsFalseForExistingFileOutsideActiveCache() + { + using TestDirectory directory = new(); + string imagePath = Path.Combine(directory.Path, "image.png"); + File.WriteAllText(imagePath, "image"); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + bool exists = service.ImageExists(imagePath); + + exists.Should().BeFalse(); + File.Exists(imagePath).Should().BeTrue(); + } + + [Fact] + public void TryDeleteImage_RemovesMatchingCachedImages() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string pngImagePath = Path.Combine(imageDirectory, "1.2.png"); + string jpgImagePath = Path.Combine(imageDirectory, "1.2.jpg"); + File.WriteAllText(pngImagePath, "png"); + File.WriteAllText(jpgImagePath, "jpg"); + FileSystemModificationImageFileService service = CreateService(paths); + + bool deleted = service.TryDeleteImage(ModificationType.Mod, "ShockWave", "1.2"); + + deleted.Should().BeTrue(); + File.Exists(pngImagePath).Should().BeFalse(); + File.Exists(jpgImagePath).Should().BeFalse(); + } + + [Fact] + public void TryDeleteImage_RejectsUnsafeCacheIdentity() + { + using TestDirectory directory = new(); + string outsideImagePath = directory.CreateFile("victim.png", "outside"); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + bool deleted = service.TryDeleteImage(ModificationType.Mod, "..", "victim"); + + deleted.Should().BeFalse(); + File.ReadAllText(outsideImagePath).Should().Be("outside"); + } + + [Fact] + public void TryDeleteImage_ReturnsTrueWhenFileDoesNotExist() + { + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + bool deleted = service.TryDeleteImage(ModificationType.Mod, "ShockWave", "missing"); + + deleted.Should().BeTrue(); + } + + [Fact] + public void FindExistingImageFilePath_RejectsLinkedImageDirectory() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string outsideDirectory = directory.CreateDirectory("OutsideImages"); + string outsideImagePath = Path.Combine(outsideDirectory, "1.2.png"); + File.WriteAllText(outsideImagePath, "outside"); + ReparsePointTestSupport.CreateDirectoryJunction( + paths.GetModificationImagesDirectory("ShockWave"), + outsideDirectory); + FileSystemModificationImageFileService service = CreateService(paths); + + Action act = () => service.FindExistingImageFilePath(ModificationType.Mod, "ShockWave", "1.2"); + + act.Should().Throw(); + File.ReadAllText(outsideImagePath).Should().Be("outside"); + } + + [Fact] + public void TryDeleteImage_DoesNotFollowLinkedImageDirectory() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string outsideDirectory = directory.CreateDirectory("OutsideImages"); + string outsideImagePath = Path.Combine(outsideDirectory, "1.2.png"); + File.WriteAllText(outsideImagePath, "outside"); + ReparsePointTestSupport.CreateDirectoryJunction( + paths.GetModificationImagesDirectory("ShockWave"), + outsideDirectory); + FileSystemModificationImageFileService service = CreateService(paths); + + bool deleted = service.TryDeleteImage(ModificationType.Mod, "ShockWave", "1.2"); + + deleted.Should().BeFalse(); + File.ReadAllText(outsideImagePath).Should().Be("outside"); + } + + [Fact] + public async Task ReplaceImageAsync_DoesNotFollowLinkedImageDirectoryAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string outsideDirectory = directory.CreateDirectory("OutsideImages"); + string outsideImagePath = Path.Combine(outsideDirectory, "1.2.jpg"); + await File.WriteAllTextAsync(outsideImagePath, "outside", TestContext.Current.CancellationToken); + ReparsePointTestSupport.CreateDirectoryJunction( + paths.GetModificationImagesDirectory("ShockWave"), + outsideDirectory); + string sourceImagePath = directory.CreateFile("selected.png", "new"); + FileSystemModificationImageFileService service = CreateService(paths); + + Func act = () => service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + await act.Should().ThrowAsync(); + (await File.ReadAllTextAsync(outsideImagePath, TestContext.Current.CancellationToken)).Should().Be("outside"); + File.Exists(Path.Combine(outsideDirectory, "1.2.png")).Should().BeFalse(); + } + + [Fact] + public async Task ReplaceImageAsync_DeletesStaleExtensionsAndCopiesSelectedImageAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string staleImagePath = Path.Combine(imageDirectory, "1.2.jpg"); + await File.WriteAllTextAsync(staleImagePath, "old", TestContext.Current.CancellationToken); + string sourceImagePath = Path.Combine(directory.Path, "selected.png"); + await File.WriteAllTextAsync(sourceImagePath, "new", TestContext.Current.CancellationToken); + FileSystemModificationImageFileService service = CreateService(paths); + + string destinationPath = await service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + destinationPath.Should().Be(Path.Combine(imageDirectory, "1.2.png")); + File.Exists(staleImagePath).Should().BeFalse(); + (await File.ReadAllTextAsync(destinationPath, TestContext.Current.CancellationToken)).Should().Be("new"); + } + + [Fact] + public async Task ReplaceImageAsyncNoOpsWhenSourceAlready_IsDestinationAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string existingImagePath = Path.Combine(imageDirectory, "1.2.png"); + await File.WriteAllTextAsync(existingImagePath, "same", TestContext.Current.CancellationToken); + FileSystemModificationImageFileService service = CreateService(paths); + + string destinationPath = await service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", existingImagePath), + CancellationToken.None); + + destinationPath.Should().Be(existingImagePath); + (await File.ReadAllTextAsync(existingImagePath, TestContext.Current.CancellationToken)).Should().Be("same"); + } + + [Fact] + public async Task ReplaceImageAsync_ThrowsForSourceWithoutExtensionAsync() + { + using TestDirectory directory = new(); + string sourceImagePath = Path.Combine(directory.Path, "selected"); + await File.WriteAllTextAsync(sourceImagePath, "new", TestContext.Current.CancellationToken); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + Func act = () => service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + // ReSharper disable once InconsistentNaming + [Fact] + public async Task ReplaceImageAsync_ThrowsIOExceptionWhenSourceCannotBeCopiedAsync() + { + using TestDirectory directory = new(); + string sourceImagePath = Path.Combine(directory.Path, "missing.png"); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + Func act = () => service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + (await act.Should().ThrowAsync() + .WithMessage("Could not replace cached image '1.2' for modification 'ShockWave'.")) + .Which.InnerException.Should().BeOfType(); + } + + [Fact] + public async Task ReplaceImageAsync_HonorsPreCanceledTokenAsync() + { + using TestDirectory directory = new(); + string sourceImagePath = Path.Combine(directory.Path, "selected.png"); + await File.WriteAllTextAsync(sourceImagePath, "new", TestContext.Current.CancellationToken); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + Func act = () => service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + cancellation.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReplaceImageAsync_UsesNewGameCacheWithoutRebuildingServiceAsync() + { + using TestDirectory directory = new(); + (LauncherRuntimePathContext runtimePaths, LauncherPaths generalsPaths, LauncherPaths zeroHourPaths) = + TestLauncherPaths.CreateTwoGameRuntime(directory); + var service = new FileSystemModificationImageFileService( + runtimePaths, + NullLogger.Instance); + string sourceImagePath = directory.CreateFile("selected.png", "image"); + var request = new ModificationImageReplacementRequest("Shared Mod", "1.0", sourceImagePath); + + string zeroHourImage = await service.ReplaceImageAsync(request, CancellationToken.None); + runtimePaths.SwitchActive(generalsPaths); + string generalsImage = await service.ReplaceImageAsync(request, CancellationToken.None); + + generalsImage.Should().StartWith(generalsPaths.ImagesDirectory); + zeroHourImage.Should().StartWith(zeroHourPaths.ImagesDirectory); + File.Exists(generalsImage).Should().BeTrue(); + File.Exists(zeroHourImage).Should().BeTrue(); + } + + /// + /// Ownership of a cache folder is not settled by the folder alone: one link below it leads somewhere the + /// launcher never created, so the lookup refuses the folder rather than reporting a path from it. + /// + [Fact] + public void FindExistingImageFilePath_RejectsImageDirectoryContainingALink() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + File.WriteAllText(Path.Combine(imageDirectory, "1.2.png"), "image"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(imageDirectory, "Linked")); + FileSystemModificationImageFileService service = CreateService(paths); + + Action act = () => service.FindExistingImageFilePath(ModificationType.Mod, "ShockWave", "1.2"); + + act.Should().Throw(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void CountImageFiles_RejectsImageDirectoryContainingALink() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + File.WriteAllText(Path.Combine(imageDirectory, "1.2.png"), "image"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(imageDirectory, "Linked")); + FileSystemModificationImageFileService service = CreateService(paths); + + Action act = () => service.CountImageFiles(ModificationType.Mod, "ShockWave"); + + act.Should().Throw(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void TryDeleteImage_KeepsCachedImagesWhenTheImageDirectoryContainsALink() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string imagePath = Path.Combine(imageDirectory, "1.2.png"); + File.WriteAllText(imagePath, "image"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(imageDirectory, "Linked")); + FileSystemModificationImageFileService service = CreateService(paths); + + bool deleted = service.TryDeleteImage(ModificationType.Mod, "ShockWave", "1.2"); + + deleted.Should().BeFalse(); + File.ReadAllText(imagePath).Should().Be("image"); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public async Task ReplaceImageAsync_WritesNothingWhenTheImageDirectoryContainsALinkAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(imageDirectory, "Linked")); + string sourceImagePath = directory.CreateFile("selected.png", "new"); + FileSystemModificationImageFileService service = CreateService(paths); + + Func act = () => service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + await act.Should().ThrowAsync(); + File.Exists(Path.Combine(imageDirectory, "1.2.png")).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + private static FileSystemModificationImageFileService CreateService(LauncherPaths paths) + { + return new FileSystemModificationImageFileService( + TestLauncherPaths.CreateRuntimePathContext(paths), + NullLogger.Instance); + } + +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationThemeCacheTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationThemeCacheTests.cs new file mode 100644 index 00000000..161c85f4 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationThemeCacheTests.cs @@ -0,0 +1,143 @@ +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Infrastructure.Persistence.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class FileSystemModificationThemeCacheTests +{ + [Fact] + public void CachedPalette_SurvivesAReadBack() + { + using TestDirectory directory = new(); + FileSystemModificationThemeCache cache = CreateCache(directory, out _); + LauncherContentTheme theme = new() + { + GenLauncherBorderColor = "#00e3ff", + GenLauncherInactiveBorder = "DarkGray", + GenLauncherActiveColor = "#baff0c", + GenLauncherBackgroundImageLink = "https://cdn.example.test/contra.png" + }; + + LauncherContentKey contentKey = CreateKey("Contra", "1.0"); + cache.Save(contentKey, theme); + + LauncherContentTheme? loaded = cache.Load(contentKey); + loaded.Should().NotBeNull(); + loaded!.GenLauncherBorderColor.Should().Be("#00e3ff"); + loaded.GenLauncherInactiveBorder.Should().Be("DarkGray"); + loaded.GenLauncherActiveColor.Should().Be("#baff0c"); + loaded.GenLauncherBackgroundImageLink.Should().Be("https://cdn.example.test/contra.png"); + loaded.GenLauncherDarkFillColor.Should().BeEmpty(); + } + + [Fact] + public void UncachedPalette_LoadsAsNothing() + { + using TestDirectory directory = new(); + FileSystemModificationThemeCache cache = CreateCache(directory, out _); + + cache.Load(CreateKey("Contra", "1.0")).Should().BeNull(); + } + + /// + /// The palette is cached inside the modification's own image folder so that removing the content card, which + /// deletes that folder, takes the palette with it rather than leaving an orphan behind. It is written as a + /// YAML document under the shared palette cache name, which is the name a later start looks for: a different + /// name orphans every palette already cached on disk instead of reading it back. + /// + [Fact] + public void CachedPalette_LivesInTheModificationImageCacheFolder() + { + using TestDirectory directory = new(); + FileSystemModificationThemeCache cache = CreateCache(directory, out LauncherPaths paths); + + cache.Save( + CreateKey("Contra", "1.0"), + new LauncherContentTheme { GenLauncherActiveColor = "#baff0c" }); + + string imageFolder = paths.GetModificationImagesDirectory("Contra"); + Directory.EnumerateFiles(imageFolder).Should().ContainSingle() + .Which.Should().Be(Path.Combine( + imageFolder, + LauncherContentTheme.ResolveCacheBaseName("1.0") + ".yaml")); + } + + /// + /// Content with no usable identity has no cache folder of its own, so the palette is dropped rather than + /// written somewhere another modification would later read it back from. + /// + [Theory] + [InlineData("", "1.0")] + [InlineData("Contra", " ")] + public void Save_KeepsNothingForBlankContentIdentity(string modificationName, string version) + { + using TestDirectory directory = new(); + FileSystemModificationThemeCache cache = CreateCache(directory, out LauncherPaths paths); + + LauncherContentKey contentKey = CreateKey(modificationName, version); + cache.Save(contentKey, new LauncherContentTheme { GenLauncherActiveColor = "#baff0c" }); + + cache.Load(contentKey).Should().BeNull(); + Directory.EnumerateFileSystemEntries(paths.ImagesDirectory).Should().BeEmpty(); + } + + [Fact] + public void Save_KeepsNothingWhenTheImageFolderIsALink() + { + using TestDirectory directory = new(); + FileSystemModificationThemeCache cache = CreateCache(directory, out LauncherPaths paths); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + paths.GetModificationImagesDirectory("Contra")); + + LauncherContentKey contentKey = CreateKey("Contra", "1.0"); + cache.Save(contentKey, new LauncherContentTheme { GenLauncherActiveColor = "#baff0c" }); + + cache.Load(contentKey).Should().BeNull(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + Directory.EnumerateFileSystemEntries(junction.TargetDirectory).Should().ContainSingle(); + } + + /// + /// A palette that cannot be read is absent, not empty: handing back an all-blank palette would re-skin the + /// shell with nothing instead of leaving the active game's own colours in place. + /// + [Theory] + [InlineData("")] + [InlineData("GenLauncherActiveColor: [unterminated")] + public void Load_ReturnsNothingForAnUnreadableCachedDocument(string cachedDocument) + { + using TestDirectory directory = new(); + FileSystemModificationThemeCache cache = CreateCache(directory, out LauncherPaths paths); + string documentPath = paths.GetModificationImageFilePath( + "Contra", + LauncherContentTheme.ResolveCacheBaseName("1.0") + ".yaml"); + Directory.CreateDirectory(Path.GetDirectoryName(documentPath)!); + File.WriteAllText(documentPath, cachedDocument); + + LauncherContentTheme? loaded = cache.Load(CreateKey("Contra", "1.0")); + + loaded.Should().BeNull(); + } + + private static FileSystemModificationThemeCache CreateCache( + TestDirectory directory, + out LauncherPaths paths) + { + paths = TestLauncherPaths.Create(directory); + return new FileSystemModificationThemeCache( + TestLauncherPaths.CreateRuntimePathContext(paths), + new AtomicFileWriter(), + NullLogger>.Instance, + NullLogger.Instance); + } + + private static LauncherContentKey CreateKey(string name, string version) + { + return new LauncherContentKey(ModificationType.Mod, string.Empty, name, version); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs new file mode 100644 index 00000000..05f1361f --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs @@ -0,0 +1,387 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherCatalogImageCacheTests +{ + [Theory] + [InlineData("https://cdn.example.test/card.jpeg", "1.2.jpeg")] + [InlineData("https://cdn.example.test/card.webp", "1.2.png")] + [InlineData("https://cdn.example.test/card", "1.2.png")] + public async Task CacheModificationImagesAsync_DownloadsCardImageToExpectedPathAsync( + string cardLink, + string expectedImageFileName) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + var cardUri = new Uri(cardLink); + var modification = new LauncherContentVersion + { + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = cardUri.ToString() + }; + + await cache.CacheModificationImagesAsync(modification, paths, CancellationToken.None); + + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == cardUri && + call.DestinationFilePath == paths.GetModificationImageFilePath("ShockWave", expectedImageFileName)); + } + + [Fact] + public async Task CacheModificationImagesAsync_SkipsEmptyImageLinksAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + var modification = new LauncherContentVersion + { + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = string.Empty + }; + + await cache.CacheModificationImagesAsync(modification, paths, CancellationToken.None); + + assetDownloader.Calls.Should().BeEmpty(); + } + + /// + /// A themed modification re-skins the shell the moment it is picked, and still does so after an offline + /// restart, which only works if both halves of the theme are cached ahead of selection. + /// + [Fact] + public async Task CacheModificationImagesAsync_CachesPublishedPaletteAndBackgroundArtworkAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + var themeCache = new FakeModificationThemeCache(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader, themeCache); + var backgroundUri = new Uri("https://cdn.example.test/background.jpg"); + var theme = new LauncherContentTheme + { + GenLauncherActiveColor = "#baff0c", + GenLauncherBackgroundImageLink = backgroundUri.ToString() + }; + var modification = new LauncherContentVersion + { + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = "https://cdn.example.test/card.png", + Theme = theme + }; + + await cache.CacheModificationImagesAsync(modification, paths, CancellationToken.None); + + themeCache.Entries[modification.ContentKey].Should().BeSameAs(theme); + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == backgroundUri && + call.DestinationFilePath == paths.GetModificationImageFilePath( + "ShockWave", + LauncherContentTheme.ResolveBackgroundImageBaseName("1.2") + ".jpg")); + } + + [Fact] + public async Task CacheModificationImagesAsync_CachesPublishedPaletteThatDeclaresNoBackgroundArtworkAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + var themeCache = new FakeModificationThemeCache(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader, themeCache); + var cardUri = new Uri("https://cdn.example.test/card.png"); + var theme = new LauncherContentTheme { GenLauncherActiveColor = "#baff0c" }; + var modification = new LauncherContentVersion + { + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = cardUri.ToString(), + Theme = theme + }; + + await cache.CacheModificationImagesAsync(modification, paths, CancellationToken.None); + + themeCache.Entries[modification.ContentKey].Should().BeSameAs(theme); + assetDownloader.Calls.Should().Equal( + (cardUri, paths.GetModificationImageFilePath("ShockWave", "1.2.png"))); + } + + [Fact] + public async Task CacheModificationImagesAsync_ContinuesWhenCardImageDownloadFailsAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + var cardUri = new Uri("https://cdn.example.test/card.png"); + var modification = new LauncherContentVersion + { + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = cardUri.ToString() + }; + assetDownloader.Handler = (_, _, _) => Task.FromException(new IOException("Download failed.")); + + await cache.CacheModificationImagesAsync(modification, paths, CancellationToken.None); + + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == cardUri && + call.DestinationFilePath == paths.GetModificationImageFilePath("ShockWave", "1.2.png")); + } + + [Fact] + public async Task CacheModificationImagesAsync_RethrowsCancellationAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + var cancellationTokenSource = new CancellationTokenSource(); + var imageUri = new Uri("https://cdn.example.test/card.png"); + var modification = new LauncherContentVersion + { + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = imageUri.ToString() + }; + cancellationTokenSource.Cancel(); + assetDownloader.Handler = (_, _, _) => Task.FromCanceled(cancellationTokenSource.Token); + + Func act = () => cache.CacheModificationImagesAsync( + modification, + paths, + cancellationTokenSource.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CacheModificationImagesAsync_DoesNotDownloadThroughLinkedImageDirectoryAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string outsideDirectory = directory.CreateDirectory("OutsideImages"); + ReparsePointTestSupport.CreateDirectoryJunction( + paths.GetModificationImagesDirectory("ShockWave"), + outsideDirectory); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + var modification = new LauncherContentVersion + { + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = "https://cdn.example.test/card.png" + }; + + await cache.CacheModificationImagesAsync(modification, paths, CancellationToken.None); + + assetDownloader.Calls.Should().BeEmpty(); + Directory.EnumerateFileSystemEntries(outsideDirectory).Should().BeEmpty(); + } + + [Fact] + public async Task CacheAdvertisingImagesAsync_DeletesStaleImagesWhenImageCountChangesAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + Directory.CreateDirectory(paths.GetModificationImagesDirectory("Featured Mod")); + string staleImagePath = paths.GetModificationImageFilePath("Featured Mod", "old.png"); + await File.WriteAllTextAsync(staleImagePath, "stale", TestContext.Current.CancellationToken); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + 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" + }); + + await cache.CacheAdvertisingImagesAsync(advertisingData, paths, CancellationToken.None); + + File.Exists(staleImagePath).Should().BeFalse(); + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == new Uri("https://cdn.example.test/0.jpg") && + call.DestinationFilePath == paths.GetModificationImageFilePath("Featured Mod", "0.jpg")); + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == new Uri("https://cdn.example.test/1.jpg") && + call.DestinationFilePath == paths.GetModificationImageFilePath("Featured Mod", "1.jpg")); + } + + [Fact] + public async Task CacheAdvertisingImagesAsync_TrimsInvalidTrailingNameCharactersLikeLegacyCacheAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + var advertisingData = new RemoteAdvertisingReference( + "Do you like GenLauncher?", + "https://example.test/advertising.yaml", + new[] { "https://cdn.example.test/0.jpg" }); + + await cache.CacheAdvertisingImagesAsync(advertisingData, paths, CancellationToken.None); + + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == new Uri("https://cdn.example.test/0.jpg") && + call.DestinationFilePath == paths.GetModificationImageFilePath("Do you like GenLauncher", "0.jpg")); + } + + [Fact] + public async Task CacheAdvertisingImagesAsync_ContinuesWhenStaleImageCannotBeDeletedAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + Directory.CreateDirectory(paths.GetModificationImagesDirectory("Featured Mod")); + string staleImagePath = paths.GetModificationImageFilePath("Featured Mod", "old.png"); + await File.WriteAllTextAsync(staleImagePath, "stale", TestContext.Current.CancellationToken); + await using FileStream lockedImage = File.Open( + staleImagePath, + FileMode.Open, + FileAccess.Read, + FileShare.None); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + 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" + }); + + Func act = () => cache.CacheAdvertisingImagesAsync( + advertisingData, + paths, + CancellationToken.None); + + await act.Should().NotThrowAsync(); + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == new Uri("https://cdn.example.test/0.jpg") && + call.DestinationFilePath == paths.GetModificationImageFilePath("Featured Mod", "0.jpg")); + } + + [Fact] + public async Task CacheAdvertisingImagesAsync_KeepsExistingImagesWhenImageCountMatchesAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + Directory.CreateDirectory(paths.GetModificationImagesDirectory("Featured Mod")); + string firstExistingImagePath = paths.GetModificationImageFilePath("Featured Mod", "0.png"); + string secondExistingImagePath = paths.GetModificationImageFilePath("Featured Mod", "1.png"); + await File.WriteAllTextAsync(firstExistingImagePath, "existing", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(secondExistingImagePath, "existing", TestContext.Current.CancellationToken); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + var advertisingData = new RemoteAdvertisingReference( + "Featured Mod", + "https://example.test/featured.yaml", + new List + { + "https://cdn.example.test/0.png", + "https://cdn.example.test/1.png" + }); + + await cache.CacheAdvertisingImagesAsync(advertisingData, paths, CancellationToken.None); + + File.Exists(firstExistingImagePath).Should().BeTrue(); + File.Exists(secondExistingImagePath).Should().BeTrue(); + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == new Uri("https://cdn.example.test/0.png") && + call.DestinationFilePath == paths.GetModificationImageFilePath("Featured Mod", "0.png")); + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == new Uri("https://cdn.example.test/1.png") && + call.DestinationFilePath == paths.GetModificationImageFilePath("Featured Mod", "1.png")); + } + + [Fact] + public async Task CacheAdvertisingImagesAsync_DoesNotMutateThroughLinkedImageDirectoryAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string outsideDirectory = directory.CreateDirectory("OutsideImages"); + string firstOutsideImage = Path.Combine(outsideDirectory, "old-1.png"); + string secondOutsideImage = Path.Combine(outsideDirectory, "old-2.png"); + await File.WriteAllTextAsync(firstOutsideImage, "first", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(secondOutsideImage, "second", TestContext.Current.CancellationToken); + ReparsePointTestSupport.CreateDirectoryJunction( + paths.GetModificationImagesDirectory("Featured Mod"), + outsideDirectory); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + var advertisingData = new RemoteAdvertisingReference( + "Featured Mod", + "https://example.test/featured.yaml", + new List + { + "https://cdn.example.test/0.jpg" + }); + + await cache.CacheAdvertisingImagesAsync(advertisingData, paths, CancellationToken.None); + + assetDownloader.Calls.Should().BeEmpty(); + (await File.ReadAllTextAsync(firstOutsideImage, TestContext.Current.CancellationToken)).Should().Be("first"); + (await File.ReadAllTextAsync(secondOutsideImage, TestContext.Current.CancellationToken)).Should().Be("second"); + } + + /// + /// A cache folder is only fully owned when nothing inside it leads out of the launcher tree. One link below it + /// is enough to stop both halves of the refresh: the stale-image sweep, which deletes files the launcher would + /// no longer be able to account for, and the download that would write into that same folder. + /// + [Fact] + public async Task CacheAdvertisingImagesAsync_KeepsCachedImagesWhenTheCacheFolderContainsALinkAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string imageFolder = paths.GetModificationImagesDirectory("Featured Mod"); + Directory.CreateDirectory(imageFolder); + string staleImagePath = Path.Combine(imageFolder, "old.png"); + await File.WriteAllTextAsync(staleImagePath, "stale", TestContext.Current.CancellationToken); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(imageFolder, "Linked")); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + 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" + }); + + await cache.CacheAdvertisingImagesAsync(advertisingData, paths, CancellationToken.None); + + File.Exists(staleImagePath).Should().BeTrue(); + assetDownloader.Calls.Should().BeEmpty(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + private static LauncherCatalogImageCache CreateCache( + RecordingRemoteAssetDownloader assetDownloader, + FakeModificationThemeCache? themeCache = null) + { + return new LauncherCatalogImageCache( + assetDownloader, + themeCache ?? new FakeModificationThemeCache(), + NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs new file mode 100644 index 00000000..646f061f --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs @@ -0,0 +1,1243 @@ +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.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Exceptions; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherContentCatalogServiceTests +{ + [Fact] + public async Task InitDataAsyncWithDisconnectedCatalog_LoadsOnlyLocalStateAsync() + { + using var harness = new CatalogTestHarness(); + harness.StateStore.StateToLoad = CreateState( + TestLauncherContent.Version("ShockWave", "1.0", installed: true)); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("ShockWave", "1.0", installed: true) + ]; + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + await harness.Service.ReadPatchesAndAddonsForModAsync( + LauncherContentKey.ForModificationName("ShockWave"), + CancellationToken.None); + + harness.Service.Data.Modifications.Select(modification => modification.Name).Should().Equal("ShockWave"); + harness.Service.RepositoryModificationNames.Should().BeNull(); + harness.YamlReader.GetReadCount().Should().Be(0); + } + + [Fact] + public async Task InitDataAsyncSwitchesGameNamespaceAnd_ClearsPreviouslyCachedContentAsync() + { + using var harness = new CatalogTestHarness(); + (LauncherPaths generalsPaths, LauncherPaths zeroHourPaths) = harness.CreateBothGamePaths(); + harness.StateStore.StatesToLoadByGame[SupportedGame.Generals] = + CreateState(TestLauncherContent.Version("Shared Mod", "Generals Version", installed: true)); + harness.StateStore.StatesToLoadByGame[SupportedGame.ZeroHour] = + CreateState(TestLauncherContent.Version("Shared Mod", "Zero Hour Version", installed: true)); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("Shared Mod", "Generals Version", installed: true) + ]; + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, generalsPaths), + CancellationToken.None); + harness.Service.Data.Modifications.Should().ContainSingle() + .Which.Versions.Should().ContainSingle() + .Which.Version.Should().Be("Generals Version"); + + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("Shared Mod", "Zero Hour Version", installed: true) + ]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, zeroHourPaths), + CancellationToken.None); + + harness.Service.Data.Modifications.Should().ContainSingle() + .Which.Versions.Should().ContainSingle() + .Which.Version.Should().Be("Zero Hour Version"); + harness.StateStore.LoadedPaths.Should().Equal(generalsPaths, zeroHourPaths); + } + + [Fact] + public async Task InitDataAsync_RestoresPreviousGameCatalogWhenSwitchInitializationFailsAsync() + { + using var harness = new CatalogTestHarness(); + (LauncherPaths generalsPaths, LauncherPaths zeroHourPaths) = harness.CreateBothGamePaths(); + var manifestUri = new Uri("https://example.test/unavailable.yaml"); + harness.YamlReader.SetException( + manifestUri, + new IOException("Catalog unavailable.")); + harness.StateStore.StatesToLoadByGame[SupportedGame.Generals] = + CreateState(TestLauncherContent.Version("Shared Mod", "Generals Version", installed: true)); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("Shared Mod", "Generals Version", installed: true) + ]; + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, generalsPaths), + CancellationToken.None); + Func switchGame = () => harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, zeroHourPaths), + CancellationToken.None); + + await switchGame.Should().ThrowAsync(); + harness.Service.Data.Modifications.Should().ContainSingle() + .Which.Versions.Should().ContainSingle() + .Which.Version.Should().Be("Generals Version"); + + harness.Service.SaveLauncherData(); + harness.StateStore.SavedPaths.Should().ContainSingle().Which.Should().Be(generalsPaths); + } + + [Fact] + public async Task InitDataAsync_ReadsRemoteCatalogForInstalledModsAndDownloadsImagesAsync() + { + using var harness = new CatalogTestHarness(); + 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"); + harness.StateStore.StateToLoad = CreateState( + TestLauncherContent.Version("ShockWave", "1.0", installed: true)); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("ShockWave", "1.0", installed: true) + ]; + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + [ + new() + { + ModName = "ShockWave", + ModLink = modUri.ToString() + } + ] + }); + harness.YamlReader.SetResult(modUri, new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = cardImageUri.ToString() + }); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + + harness.Service.RepositoryModificationNames.Should().Equal("ShockWave"); + LauncherContent mod = harness.Service.Data.Modifications + .Should() + .ContainSingle(item => item.Name == "ShockWave") + .Subject; + mod.Versions.Should().Contain(version => version.Version == "1.2"); + harness.AssetDownloader.Calls.Should().Equal( + (cardImageUri, harness.Paths.GetModificationImageFilePath("ShockWave", "1.2.jpg"))); + } + + /// + /// Persisted state carries no remote metadata, so a themed launcher only survives an offline restart because + /// the palette that came down with the manifest was cached beside the artwork it belongs to. + /// + [Fact] + public async Task InitDataAsync_RestoresCachedPaletteWhenReopenedWithoutTheCatalogAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/contra.yaml"); + harness.StateStore.StateToLoad = CreateState( + TestLauncherContent.Version("Contra", "009", installed: true)); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("Contra", "009", installed: true) + ]; + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + [ + new() + { + ModName = "Contra", + ModLink = modUri.ToString() + } + ] + }); + harness.YamlReader.SetResult(modUri, new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "009", + ColorsInformation = new LegacyContentThemeManifest { GenLauncherActiveColor = "#baff0c" } + }); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + harness.Service.SaveLauncherData(); + harness.StateStore.StateToLoad = harness.StateStore.SavedStates.Single(); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + + LauncherContentVersion restored = harness.Service.Data.Modifications.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject; + restored.Theme.Should().NotBeNull(); + restored.Theme!.GenLauncherActiveColor.Should().Be("#baff0c"); + } + + [Fact] + public async Task InitDataAsync_LoadsSelectedModPatchesAndAddonsAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/shockwave.yaml"); + var patchUri = new Uri("https://example.test/patch.yaml"); + var addonUri = new Uri("https://example.test/addon.yaml"); + harness.StateStore.StateToLoad = LauncherContentStateMapper.ToLauncherContentState( + TestLauncherContent.Catalog() + .WithMod("ShockWave", "1.0") + .Selected("ShockWave") + .Build() + .Data); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("ShockWave", "1.0", installed: true) + ]; + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + [ + new() + { + ModName = "ShockWave", + ModLink = modUri.ToString(), + ModPatches = [patchUri.ToString()], + ModAddons = [addonUri.ToString()] + } + ] + }); + harness.YamlReader.SetResult( + modUri, + CreateRemoteVersion("ShockWave", "1.2", ModificationType.Mod)); + harness.YamlReader.SetResult( + patchUri, + CreateRemoteVersion("Balance", "2.0", ModificationType.Patch, "ShockWave")); + harness.YamlReader.SetResult( + addonUri, + CreateRemoteVersion("HD", "1.0", ModificationType.Addon, "ShockWave")); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + await harness.Service.ReadPatchesAndAddonsForModAsync( + LauncherContentKey.ForModificationName("ShockWave"), + CancellationToken.None); + + LauncherContent shockWave = harness.Service.Data.Modifications.Should().ContainSingle().Subject; + harness.Service.Data.GetPatchesFor(shockWave).Select(patch => patch.Name).Should().Equal("Balance"); + harness.Service.Data.GetAddonsFor(shockWave, null).Select(addon => addon.Name).Should().Equal("HD"); + harness.YamlReader.GetReadCount(patchUri).Should().Be(1); + harness.YamlReader.GetReadCount(addonUri).Should().Be(1); + } + + [Fact] + public async Task ReadPatchesAndAddonsForModAsync_RetriesAfterPartialLoadFailureAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/shockwave.yaml"); + var patchUri = new Uri("https://example.test/patch.yaml"); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + { + new LegacyCatalogModificationReference + { + ModName = "ShockWave", + ModLink = modUri.ToString(), + ModPatches = { patchUri.ToString() } + } + } + }); + harness.YamlReader.SetResult( + modUri, + CreateRemoteVersion("ShockWave", "1.2", ModificationType.Mod)); + harness.YamlReader.SetHandler( + patchUri, + (callIndex, _) => callIndex == 1 + ? Task.FromException(new IOException("Temporary failure.")) + : Task.FromResult(CreateRemoteVersion( + "Balance", + "2.0", + ModificationType.Patch, + "ShockWave"))); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + await harness.Service.AddRepositoryModificationAsync("ShockWave", CancellationToken.None); + var modification = LauncherContentKey.ForModificationName("ShockWave"); + + await harness.Service.ReadPatchesAndAddonsForModAsync(modification, CancellationToken.None); + await harness.Service.ReadPatchesAndAddonsForModAsync(modification, CancellationToken.None); + await harness.Service.ReadPatchesAndAddonsForModAsync(modification, CancellationToken.None); + + harness.Service.Data.Patches.SelectMany(patch => patch.Versions).Should() + .ContainSingle(version => version.Name == "Balance" && version.Version == "2.0"); + harness.YamlReader.GetReadCount(patchUri).Should().Be(2); + } + + [Fact] + public async Task ReadPatchesAndAddonsForModAsyncCoalescesConcurrent_LoadsAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/shockwave.yaml"); + var patchUri = new Uri("https://example.test/patch.yaml"); + var patchReadStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releasePatchRead = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + { + new LegacyCatalogModificationReference + { + ModName = "ShockWave", + ModLink = modUri.ToString(), + ModPatches = { patchUri.ToString() } + } + } + }); + harness.YamlReader.SetResult( + modUri, + CreateRemoteVersion("ShockWave", "1.2", ModificationType.Mod)); + harness.YamlReader.SetHandler( + patchUri, + (_, _) => + { + patchReadStarted.TrySetResult(true); + return releasePatchRead.Task; + }); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + await harness.Service.AddRepositoryModificationAsync("ShockWave", CancellationToken.None); + var modification = LauncherContentKey.ForModificationName("ShockWave"); + + Task firstLoad = harness.Service.ReadPatchesAndAddonsForModAsync(modification, CancellationToken.None); + await patchReadStarted.Task.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + Task secondLoad = harness.Service.ReadPatchesAndAddonsForModAsync(modification, CancellationToken.None); + releasePatchRead.SetResult( + CreateRemoteVersion("Balance", "2.0", ModificationType.Patch, "ShockWave")); + await Task.WhenAll(firstLoad, secondLoad); + + harness.Service.Data.Patches.SelectMany(patch => patch.Versions).Should() + .ContainSingle(version => version.Name == "Balance" && version.Version == "2.0"); + harness.YamlReader.GetReadCount(patchUri).Should().Be(1); + } + + [Fact] + public async Task ReadOriginalGameAddonsAndPatchesAsync_LoadsChildContentOnceAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var patchUri = new Uri("https://example.test/original-patch.yaml"); + var addonUri = new Uri("https://example.test/original-addon.yaml"); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + originalGamePatches = [patchUri.ToString()], + originalGameAddons = [addonUri.ToString()] + }); + harness.YamlReader.SetResult( + patchUri, + CreateRemoteVersion("GenPatcher", "1.0", ModificationType.Patch)); + harness.YamlReader.SetResult( + addonUri, + CreateRemoteVersion("ControlBar", "1.0", ModificationType.Addon)); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + await harness.Service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + await harness.Service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + harness.Service.UpdateLocalModificationsData(); + + harness.Service.Data.GetPatchesFor(null).Select(patch => patch.Name).Should().Equal("GenPatcher"); + harness.Service.Data.GetAddonsFor(null, null).Select(addon => addon.Name).Should().Equal("ControlBar"); + harness.YamlReader.GetReadCount(patchUri).Should().Be(1); + harness.YamlReader.GetReadCount(addonUri).Should().Be(1); + } + + [Fact] + public async Task ReadOriginalGameAddonsAndPatchesAsync_RetriesAfterPartialLoadFailureAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var patchUri = new Uri("https://example.test/original-patch.yaml"); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + originalGamePatches = { patchUri.ToString() } + }); + harness.YamlReader.SetHandler( + patchUri, + (callIndex, _) => callIndex == 1 + ? Task.FromException(new IOException("Temporary failure.")) + : Task.FromResult(CreateRemoteVersion("GenPatcher", "1.0", ModificationType.Patch))); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + + await harness.Service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + await harness.Service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + await harness.Service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + + harness.Service.Data.Patches.SelectMany(patch => patch.Versions).Should() + .ContainSingle(version => version.Name == "GenPatcher"); + harness.YamlReader.GetReadCount(patchUri).Should().Be(2); + } + + [Fact] + public async Task ReadOriginalGameAddonsAndPatchesAsync_ReturnsWhenCatalogIsDisconnectedAsync() + { + using var harness = new CatalogTestHarness(); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + + await harness.Service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + + harness.YamlReader.GetReadCount().Should().Be(0); + } + + [Fact] + public async Task ReadPatchesAndAddonsForModAsync_ReturnsWhenManifestLookupIsMissingAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument()); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + + await harness.Service.ReadPatchesAndAddonsForModAsync( + LauncherContentKey.ForModificationName("Missing"), + CancellationToken.None); + + harness.YamlReader.GetReadCount().Should().Be(0); + } + + [Fact] + public async Task InitDataAsync_DownloadsAdvertisingMetadataAndImagesAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var advertisingUri = new Uri("https://example.test/advertising.yaml"); + var imageUri = new Uri("https://cdn.example.test/advertising.jpg"); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + AdvData = + [ + new() + { + ModName = "RiseOfTheReds", + ModLink = advertisingUri.ToString(), + ImagesData = [imageUri.ToString()] + } + ] + }); + harness.YamlReader.SetResult(advertisingUri, new LegacyContentManifest + { + ModificationType = ModificationType.Advertising, + Name = "RiseOfTheReds", + Version = "1.87" + }); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + + LauncherContentVersion? advertising = harness.Service.Advertising; + advertising.Should().NotBeNull(); + advertising!.Name.Should().Be("RiseOfTheReds"); + advertising.Version.Should().Be("1.87"); + harness.AssetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == imageUri && + call.DestinationFilePath == + harness.Paths.GetModificationImageFilePath("RiseOfTheReds", "0.jpg")); + } + + [Fact] + public async Task InitDataAsync_LeavesAdvertisingEmptyWhenManifestDownloadFailsAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var advertisingUri = new Uri("https://example.test/advertising.yaml"); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + AdvData = + [ + new() + { + ModName = "RiseOfTheReds", + ModLink = advertisingUri.ToString(), + ImagesData = ["https://cdn.example.test/advertising.jpg"] + } + ] + }); + harness.YamlReader.SetException( + advertisingUri, + new IOException("Manifest unavailable.")); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + + harness.Service.Advertising.Should().BeNull(); + harness.AssetDownloader.Calls.Should().BeEmpty(); + } + + [Fact] + public async Task PersistedSelectionState_IsLoadedAsync() + { + using var harness = new CatalogTestHarness(); + LauncherData persistedCatalog = TestLauncherContent.Catalog() + .WithMod("ShockWave", "1.2") + .WithMod("Contra", "009") + .WithPatch("ShockWave", "BalancePatch", "2.0") + .WithAddon("ShockWave", "HDTextures", "1.0") + .WithAddon("BalancePatch", "PatchAddon", "1.1") + .Selected("ShockWave") + .Selected("BalancePatch", ModificationType.Patch, "ShockWave") + .Selected("HDTextures", ModificationType.Addon, "ShockWave") + .Selected("PatchAddon", ModificationType.Addon, "BalancePatch") + .Build() + .Data; + var catalogState = LauncherContentStateMapper.ToLauncherContentState(persistedCatalog); + harness.StateStore.StateToLoad = catalogState; + harness.LocalContent.InstalledVersions = GetVersions(catalogState); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + + LauncherContent selectedModification = harness.Service.Data.Modifications.Single(modification => + modification.IsSelected); + LauncherContent selectedPatch = harness.Service.Data.Patches.Single(patch => patch.IsSelected); + selectedModification.Versions.Should().ContainSingle(version => + version.Name == "ShockWave" && version.Version == "1.2" && version.Installation.IsSelected); + selectedPatch.Versions.Should().ContainSingle(version => + version.Name == "BalancePatch" && version.Version == "2.0" && version.Installation.IsSelected); + harness.Service.Data.GetPatchesFor(selectedModification) + .Should().ContainSingle(patch => patch.Name == "BalancePatch"); + harness.Service.Data.GetAddonsFor(selectedModification, selectedPatch) + .Should().Contain(addon => addon.Name == "HDTextures") + .And.Contain(addon => addon.Name == "PatchAddon"); + harness.Service.Data.Addons.Should().OnlyContain(addon => + addon.IsSelected && + addon.Versions.Count(version => version.Installation.IsSelected) == 1); + harness.Service.Data.GetAllModificationVersions().Should().HaveCount(2); + } + + [Fact] + public async Task UninstallVersion_DeletesLocalFilesAndReconcilesCatalogAsync() + { + using var harness = new CatalogTestHarness(); + LauncherContentVersion version = TestLauncherContent.Version("ShockWave", "1.0", installed: true); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + + harness.Service.UninstallVersion(version.ContentKey); + + harness.LocalContent.DeletedVersions.Should().ContainSingle(request => + request.Paths == harness.Paths && + request.ContentKey.ContentType == ModificationType.Mod && + request.ContentKey.Name == "ShockWave" && + request.ContentKey.Version == "1.0"); + harness.LocalContent.ImageDeletionRequests.Should().BeEmpty(); + } + + [Fact] + public async Task AddRepositoryModificationAsync_AddsRemoteModAndCachesImagesExactlyOnceAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/contra.yaml"); + var imageUri = new Uri("https://cdn.example.test/contra.png"); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + [ + new() + { + ModName = "Contra", + ModLink = modUri.ToString() + } + ] + }); + harness.YamlReader.SetResult(modUri, new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "009", + UIImageSourceLink = imageUri.ToString() + }); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + + LauncherContentVersion downloadedVersion = await harness.Service.AddRepositoryModificationAsync( + "Contra", + CancellationToken.None); + + downloadedVersion.Name.Should().Be("Contra"); + downloadedVersion.Version.Should().Be("009"); + LauncherContent addedModification = harness.Service.Data.Modifications.Should().ContainSingle().Subject; + addedModification.Name.Should().Be("Contra"); + addedModification.Versions.Should().ContainSingle().Which.Should().BeSameAs(downloadedVersion); + harness.AssetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == imageUri && + call.DestinationFilePath == harness.Paths.GetModificationImageFilePath("Contra", "009.png")); + } + + [Fact] + public async Task InitDataAsync_WaitsForOldGameMetadataLoadBeforeSwitchingCatalogAsync() + { + using var harness = new CatalogTestHarness(); + (LauncherPaths generalsPaths, LauncherPaths zeroHourPaths) = harness.CreateBothGamePaths(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/contra.yaml"); + var imageUri = new Uri("https://cdn.example.test/contra.png"); + var metadataReadStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseMetadataRead = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + { + new LegacyCatalogModificationReference + { + ModName = "Contra", + ModLink = modUri.ToString() + } + } + }); + harness.YamlReader.SetHandler( + modUri, + async (_, cancellationToken) => + { + metadataReadStarted.SetResult(); + await releaseMetadataRead.Task.WaitAsync(cancellationToken); + return new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "009", + UIImageSourceLink = imageUri.ToString() + }; + }); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, generalsPaths), + CancellationToken.None); + Task oldGameDownload = + harness.Service.AddRepositoryModificationAsync("Contra", CancellationToken.None); + await metadataReadStarted.Task; + Task switchGame = harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, zeroHourPaths), + CancellationToken.None); + + releaseMetadataRead.SetResult(); + await oldGameDownload; + await switchGame; + + harness.Service.Data.Modifications.Should().BeEmpty(); + harness.AssetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == imageUri && + call.DestinationFilePath == generalsPaths.GetModificationImageFilePath("Contra", "009.png")); + } + + [Fact] + public async Task InitDataAsync_WaitsForDirectMetadataReadBeforeSwitchingCatalogAsync() + { + using var harness = new CatalogTestHarness(); + (LauncherPaths generalsPaths, LauncherPaths zeroHourPaths) = harness.CreateBothGamePaths(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/contra.yaml"); + var metadataReadStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseMetadataRead = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + { + new LegacyCatalogModificationReference + { + ModName = "Contra", + ModLink = modUri.ToString() + } + } + }); + harness.YamlReader.SetHandler( + modUri, + async (_, cancellationToken) => + { + metadataReadStarted.SetResult(); + await releaseMetadataRead.Task.WaitAsync(cancellationToken); + return new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "009" + }; + }); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, generalsPaths), + CancellationToken.None); + Task oldGameMetadata = harness.Service.GetRepositoryModificationMetadataAsync( + "Contra", + CancellationToken.None); + await metadataReadStarted.Task; + Task switchGame = harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, zeroHourPaths), + CancellationToken.None); + + bool switchCompletedBeforeMetadataRead = switchGame.IsCompleted; + releaseMetadataRead.SetResult(); + LauncherContentVersion metadata = await oldGameMetadata; + await switchGame; + + switchCompletedBeforeMetadataRead.Should().BeFalse(); + metadata.Version.Should().Be("009"); + harness.Service.Data.Modifications.Should().BeEmpty(); + Func readOldMetadataFromNewSession = () => harness.Service.GetRepositoryModificationMetadataAsync( + "Contra", + CancellationToken.None); + await readOldMetadataFromNewSession.Should().ThrowAsync(); + } + + [Fact] + public async Task DiscardVersion_DeletesFolderAndCatalogVersionAsync() + { + using var harness = new CatalogTestHarness(); + LauncherContentVersion version = TestLauncherContent.Version("ShockWave", "1.0", installed: true); + harness.StateStore.StateToLoad = CreateState(version); + harness.LocalContent.InstalledVersions = [version]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + harness.LocalContent.InstalledVersions = []; + + harness.Service.DiscardVersion(version.ContentKey); + + harness.Service.Data.Modifications.Select(modification => modification.Name).Should() + .NotContain("ShockWave"); + harness.LocalContent.DeletedVersions.Should().ContainSingle(request => + request.Paths == harness.Paths && + request.ContentKey.ContentType == ModificationType.Mod && + request.ContentKey.Name == "ShockWave" && + request.ContentKey.Version == "1.0"); + harness.LocalContent.ImageDeletionRequests.Should().ContainSingle(request => + request.Paths == harness.Paths && + request.ContentKey == version.ContentKey && + !request.ContentNames.Contains("ShockWave") && + ReferenceEquals(request.Data, harness.Service.Data)); + } + + [Fact] + public async Task DiscardContent_DeletesEveryVersionFolderAndCatalogEntryAsync() + { + using var harness = new CatalogTestHarness(); + LauncherContentVersion version = TestLauncherContent.Version("ShockWave", "1.0", installed: true); + LauncherContentVersion secondVersion = TestLauncherContent.Version("ShockWave", "2.0", installed: true); + harness.StateStore.StateToLoad = CreateState(version, secondVersion); + harness.LocalContent.InstalledVersions = [version, secondVersion]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + harness.LocalContent.InstalledVersions = []; + + harness.Service.DiscardContent(version.ContentKey); + + harness.Service.Data.Modifications.Select(modification => modification.Name).Should() + .NotContain("ShockWave"); + harness.LocalContent.DeletedContents.Should().ContainSingle(request => + request.Paths == harness.Paths && + request.ContentKey.ContentType == ModificationType.Mod && + request.ContentKey.Name == "ShockWave" && + request.ContentKey.Version == "1.0"); + harness.LocalContent.DeletedVersions.Should().BeEmpty(); + harness.LocalContent.ImageDeletionRequests.Should().ContainSingle(request => + request.Paths == harness.Paths && + request.ContentKey == version.ContentKey && + !request.ContentNames.Contains("ShockWave") && + ReferenceEquals(request.Data, harness.Service.Data)); + } + + [Fact] + public async Task SaveLauncherData_PersistsInstalledAndAddedRepositoryModsAsync() + { + using var harness = new CatalogTestHarness(); + LauncherContentVersion installedVersion = TestLauncherContent.Version( + "ShockWave", + "1.0", + installed: true, + isSelected: true); + LauncherContentVersion repositoryVersion = TestLauncherContent.Version( + "Contra", + "2.0", + sourceKind: ContentSourceKind.ManagedSingleFile); + harness.StateStore.StateToLoad = CreateState(installedVersion, repositoryVersion); + harness.LocalContent.InstalledVersions = [installedVersion]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + + harness.Service.SaveLauncherData(); + + harness.StateStore.SavedStates.Should().ContainSingle(state => + state.Modifications.Count == 2 && + 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 && + state.Modifications[1].Name == "Contra" && + state.Modifications[1].ModificationVersions.Count == 1 && + state.Modifications[1].ModificationVersions[0].Version == "2.0" && + !state.Modifications[1].ModificationVersions[0].Installed); + } + + [Fact] + public async Task PersistedManagedRepositoryMod_SurvivesDisconnectedCatalogReloadAsync() + { + using var harness = new CatalogTestHarness(); + harness.StateStore.StateToLoad = CreateState(TestLauncherContent.Version( + "Contra", + "2.0", + sourceKind: ContentSourceKind.ManagedSingleFile)); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + harness.Service.SaveLauncherData(); + harness.StateStore.StateToLoad = harness.StateStore.SavedStates.Single(); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + + harness.Service.Data.Modifications.Should().ContainSingle() + .Which.Name.Should().Be("Contra"); + } + + [Fact] + public async Task SaveLauncherData_PersistsOriginalGameSelectionWithoutModificationCardsAsync() + { + using var harness = new CatalogTestHarness(); + LauncherContentVersion patch = TestLauncherContent.Version( + "Original Patch", + "1.0", + ModificationType.Patch, + LauncherContentKey.OriginalGame.Name, + installed: true, + isSelected: true); + harness.StateStore.StateToLoad = CreateState(patch); + harness.LocalContent.InstalledVersions = [patch]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + + harness.Service.SaveLauncherData(); + + LauncherContentState savedState = harness.StateStore.SavedStates.Should().ContainSingle().Subject; + savedState.Modifications.Should().BeEmpty(); + LauncherContentEntryState savedPatch = savedState.Patches.Should().ContainSingle().Subject; + savedPatch.Name.Should().Be("Original Patch"); + savedPatch.IsSelected.Should().BeTrue(); + savedPatch.ModificationVersions.Should().ContainSingle().Which.IsSelected.Should().BeTrue(); + } + + [Fact] + public async Task SaveLauncherDataWhenPersistence_FailsPreservesCatalogForRetryAsync() + { + using var harness = new CatalogTestHarness(); + int saveAttempts = 0; + harness.StateStore.SaveHandler = _ => + { + saveAttempts++; + if (saveAttempts == 1) + { + throw new IOException("Catalog file is locked."); + } + }; + LauncherContentVersion version = TestLauncherContent.Version( + "ShockWave", + "1.2", + installed: true, + sourceKind: ContentSourceKind.Manual); + harness.StateStore.StateToLoad = CreateState(version); + harness.LocalContent.InstalledVersions = [version]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + Action firstSave = harness.Service.SaveLauncherData; + + firstSave.Should().Throw() + .WithInnerException(); + harness.Service.Data.Modifications.Should().ContainSingle(modification => + modification.Name == "ShockWave" && + modification.Versions.Single().Installation.Installed); + + harness.Service.SaveLauncherData(); + + saveAttempts.Should().Be(2); + harness.StateStore.SavedStates.Should().HaveCount(2); + harness.StateStore.SavedStates.Should().OnlyContain(state => + state.Modifications.Count == 1 && + state.Modifications[0].Name == "ShockWave" && + state.Modifications[0].ModificationVersions[0].ContentSourceKind == ContentSourceKind.Manual); + } + + /// + /// Saved state is only half the picture. A modification whose folder is on disk but which the state file never + /// recorded — a crash between installing and saving is enough — still has to appear in the list. + /// + [Fact] + public async Task InitDataAsync_AddsInstalledContentThatSavedStateDoesNotRecordAsync() + { + using var harness = new CatalogTestHarness(); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("ShockWave", "1.0", installed: true) + ]; + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + + harness.Service.Data.Modifications.Should().ContainSingle() + .Which.Versions.Should().ContainSingle() + .Which.Version.Should().Be("1.0"); + } + + /// + /// Removing a version rescans the mods folder in the same pass, so what the list shows afterwards is what is + /// actually on disk rather than what was there when the session started. + /// + [Fact] + public async Task UninstallVersion_RescansLocalContentAsync() + { + using var harness = new CatalogTestHarness(); + LauncherContentVersion version = TestLauncherContent.Version("ShockWave", "1.0", installed: true); + harness.StateStore.StateToLoad = CreateState(version); + harness.LocalContent.InstalledVersions = [version]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("Contra", "009", installed: true) + ]; + + harness.Service.UninstallVersion(version.ContentKey); + + harness.Service.Data.Modifications.Select(modification => modification.Name).Should().Equal("Contra"); + } + + [Fact] + public async Task DiscardVersion_RescansLocalContentAsync() + { + using var harness = new CatalogTestHarness(); + LauncherContentVersion version = TestLauncherContent.Version("ShockWave", "1.0", installed: true); + harness.StateStore.StateToLoad = CreateState(version); + harness.LocalContent.InstalledVersions = [version]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("Contra", "009", installed: true) + ]; + + harness.Service.DiscardVersion(version.ContentKey); + + harness.Service.Data.Modifications.Select(modification => modification.Name).Should().Equal("Contra"); + } + + [Fact] + public async Task DiscardContent_RescansLocalContentAsync() + { + using var harness = new CatalogTestHarness(); + LauncherContentVersion version = TestLauncherContent.Version("ShockWave", "1.0", installed: true); + harness.StateStore.StateToLoad = CreateState(version); + harness.LocalContent.InstalledVersions = [version]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("Contra", "009", installed: true) + ]; + + harness.Service.DiscardContent(version.ContentKey); + + harness.Service.Data.Modifications.Select(modification => modification.Name).Should().Equal("Contra"); + } + + /// + /// A version the catalog still publishes stays on its card as "not installed" when its folder is gone, so the + /// user can download it again. Only content nobody publishes any more is dropped from the list outright. + /// + [Fact] + public async Task UninstallVersion_KeepsARemotelyPublishedVersionAsNotInstalledAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/shockwave.yaml"); + LauncherContentVersion version = TestLauncherContent.Version("ShockWave", "1.2", installed: true); + harness.StateStore.StateToLoad = CreateState(version); + harness.LocalContent.InstalledVersions = [version]; + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + [ + new() + { + ModName = "ShockWave", + ModLink = modUri.ToString() + } + ] + }); + harness.YamlReader.SetResult(modUri, new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2" + }); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + harness.LocalContent.InstalledVersions = []; + + harness.Service.UninstallVersion(version.ContentKey); + + LauncherContentVersion remaining = harness.Service.Data.Modifications.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject; + remaining.Version.Should().Be("1.2"); + remaining.Installation.Installed.Should().BeFalse(); + } + + /// + /// Repository metadata is reused once read. The details pane asks for it every time it is opened, and each + /// read is a round trip to a third-party backend. + /// + [Fact] + public async Task GetRepositoryModificationMetadataAsync_ReadsTheManifestOnceForRepeatedRequestsAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/contra.yaml"); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + [ + new() + { + ModName = "Contra", + ModLink = modUri.ToString() + } + ] + }); + harness.YamlReader.SetResult(modUri, new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "009" + }); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + + LauncherContentVersion firstRead = await harness.Service.GetRepositoryModificationMetadataAsync( + "Contra", + CancellationToken.None); + LauncherContentVersion secondRead = await harness.Service.GetRepositoryModificationMetadataAsync( + "Contra", + CancellationToken.None); + + secondRead.Should().BeSameAs(firstRead); + harness.YamlReader.GetReadCount(modUri).Should().Be(1); + } + + /// + /// Image caching runs a bounded number of downloads at a time so a first start does not saturate the + /// connection. The bound limits how many run at once, not how many are cached. + /// + [Fact] + public async Task InitDataAsync_CachesImagesForMoreModificationsThanItDownloadsConcurrentlyAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var catalogReferences = new List(); + var installedVersions = new List(); + for (int index = 0; index < 12; index++) + { + var modUri = new Uri($"https://example.test/mod-{index}.yaml"); + harness.YamlReader.SetResult(modUri, new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = $"Mod {index}", + Version = "1.0", + UIImageSourceLink = $"https://cdn.example.test/mod-{index}.png" + }); + catalogReferences.Add(new LegacyCatalogModificationReference + { + ModName = $"Mod {index}", + ModLink = modUri.ToString() + }); + installedVersions.Add(TestLauncherContent.Version($"Mod {index}", "1.0", installed: true)); + } + + harness.StateStore.StateToLoad = CreateState([.. installedVersions]); + harness.LocalContent.InstalledVersions = installedVersions; + harness.YamlReader.SetResult( + manifestUri, + new LegacyLauncherCatalogDocument { modDatas = catalogReferences }); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + + harness.AssetDownloader.Calls.Should().HaveCount(12); + } + + private static LauncherContentState CreateState(params LauncherContentVersion[] versions) + { + var data = new LauncherData(); + foreach (LauncherContentVersion version in versions) + { + data.AddOrUpdate(version); + } + + return LauncherContentStateMapper.ToLauncherContentState(data); + } + + private static LegacyContentManifest CreateRemoteVersion( + string name, + string version, + ModificationType modificationType, + string parentContentName = "") + { + return new LegacyContentManifest + { + ModificationType = modificationType, + Name = name, + Version = version, + DependenceName = parentContentName + }; + } + + private static IReadOnlyList GetVersions(LauncherContentState state) + { + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + return launcherData.Modifications + .Concat(launcherData.Patches) + .Concat(launcherData.Addons) + .SelectMany(modification => modification.Versions) + .ToList(); + } +} + +/// +/// Owns one catalog session: its temporary directory, its collaborators, and the service built from them, so a +/// test arranges only the part it is actually about. +/// +/// +/// The service is built on first use, which is what lets a test finish configuring the stubs it needs first. One +/// palette cache instance is shared between the image cache and the catalog, as in production: what the download +/// path writes is what a later offline start reads back. +/// +file sealed class CatalogTestHarness : IDisposable +{ + private readonly TestDirectory _directory = new(); + + private readonly Lazy _paths; + + private readonly Lazy _service; + + public CatalogTestHarness() + { + _paths = new Lazy(() => TestLauncherPaths.Create(_directory)); + _service = new Lazy(BuildService); + } + + public RecordingLauncherContentStateStore StateStore { get; } = new(); + + public RecordingLocalLauncherContentService LocalContent { get; } = new(); + + public StubRemoteYamlDocumentReader YamlReader { get; } = new(); + + public RecordingRemoteAssetDownloader AssetDownloader { get; } = new(); + + public IModificationThemeCache ThemeCache { get; } = new FakeModificationThemeCache(); + + public LauncherPaths Paths => _paths.Value; + + public LauncherContentCatalogService Service => _service.Value; + + public void Dispose() + { + _directory.Dispose(); + } + + /// + /// Builds both supported games from one storage root, which is the only arrangement a game switch is valid in. + /// + public (LauncherPaths Generals, LauncherPaths ZeroHour) CreateBothGamePaths() + { + (_, LauncherPaths generalsPaths, LauncherPaths zeroHourPaths) = + TestLauncherPaths.CreateTwoGameRuntime(_directory); + return (generalsPaths, zeroHourPaths); + } + + private LauncherContentCatalogService BuildService() + { + return new LauncherContentCatalogService( + StateStore, + new RemoteLauncherCatalogClient( + YamlReader, + NullLogger.Instance), + new LauncherCatalogImageCache( + AssetDownloader, + ThemeCache, + NullLogger.Instance), + new LauncherLocalContentReconciler( + LocalContent, + NullLogger.Instance), + ThemeCache, + NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentStateMapperTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentStateMapperTests.cs new file mode 100644 index 00000000..07b1c61a --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentStateMapperTests.cs @@ -0,0 +1,702 @@ +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherContentStateMapperTests +{ + [Fact] + public void ToLauncherData_RestoresSelectedInstalledContentState() + { + var state = new LauncherContentState + { + Modifications = + [ + CreateEntry("ShockWave", string.Empty, ModificationType.Mod, "1.0", true) + ], + Patches = + [ + CreateEntry("ShockWave Patch", "ShockWave", ModificationType.Patch, "1.1", true) + ], + Addons = + [ + CreateEntry("Music Pack", "ShockWave Patch", ModificationType.Addon, "2.0", true) + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + LauncherContentVersion modVersion = launcherData.Modifications.Should().ContainSingle().Subject + .Versions.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.Installation.Installed.Should().BeTrue(); + modVersion.Installation.IsSelected.Should().BeTrue(); + + LauncherContentVersion patchVersion = launcherData.Patches.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject; + patchVersion.Name.Should().Be("ShockWave Patch"); + patchVersion.ParentContentName.Should().Be("ShockWave"); + patchVersion.ModificationType.Should().Be(ModificationType.Patch); + + LauncherContentVersion addonVersion = launcherData.Addons.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject; + addonVersion.Name.Should().Be("Music Pack"); + addonVersion.ParentContentName.Should().Be("ShockWave Patch"); + addonVersion.ModificationType.Should().Be(ModificationType.Addon); + } + + [Fact] + public void ToLauncherData_RestoresPersistedEntryOrder() + { + var state = new LauncherContentState + { + Modifications = + [ + CreateEntry("Second", string.Empty, ModificationType.Mod, "1.0", false, numberInList: 1), + CreateEntry("First", string.Empty, ModificationType.Mod, "1.0", false, numberInList: 0) + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Modifications + .OrderBy(modification => modification.NumberInList) + .Select(modification => modification.Name) + .Should() + .Equal("First", "Second"); + } + + [Fact] + public void ToLauncherData_DoesNotSelectEntryFromStaleVersionSelection() + { + var state = new LauncherContentState + { + Modifications = + [ + CreateEntry("ShockWave", string.Empty, ModificationType.Mod, "1.0", false, true) + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + LauncherContent modification = launcherData.Modifications.Should().ContainSingle().Subject; + modification.IsSelected.Should().BeFalse(); + modification.Versions.Should().ContainSingle().Which.Installation.IsSelected.Should().BeFalse(); + } + + [Fact] + public void ToLauncherData_UsesEntryTypeForIncompleteLegacyChildVersionRecords() + { + var state = new LauncherContentState + { + Addons = + [ + new() + { + Name = "Compatibility Addon", + DependenceName = "ShockWave", + ModificationType = ModificationType.Addon, + ModificationVersions = + [ + new() + { + Version = "1.0", + Installed = true, + ContentSourceKind = ContentSourceKind.Manual + } + ] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + LauncherContentVersion version = launcherData.Addons.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject; + version.Name.Should().Be("Compatibility Addon"); + version.ParentContentName.Should().Be("ShockWave"); + version.ModificationType.Should().Be(ModificationType.Addon); + version.Installation.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + } + + /// + /// Legacy records can leave both type slots at their default, which reads as "mod" for a record that is + /// stored under the add-ons section. The section it is stored in is what settles it. + /// + [Fact] + public void ToLauncherData_UsesTheStoredSectionWhenNeitherEntryNorVersionRecordsAChildType() + { + var state = new LauncherContentState + { + Addons = + [ + new() + { + Name = "Compatibility Addon", + DependenceName = "ShockWave", + ModificationVersions = + [ + new() + { + Version = "1.0", + Installed = true + } + ] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Addons.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject + .ModificationType.Should().Be(ModificationType.Addon); + } + + /// + /// Older saved data recorded installation on the card rather than on each version, so an entry that claims to + /// be installed still has to produce an installed version. + /// + [Fact] + public void ToLauncherData_TreatsAVersionAsInstalledWhenOnlyItsEntrySaysSo() + { + var state = new LauncherContentState + { + Modifications = + [ + new() + { + Name = "ShockWave", + ModificationType = ModificationType.Mod, + Installed = true, + ModificationVersions = + [ + new() + { + Version = "1.0", + Installed = false + } + ] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Modifications.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject + .Installation.Installed.Should().BeTrue(); + } + + /// + /// Persisted state deliberately holds no remote metadata, so the palette a themed modification wears after an + /// offline restart can only come from the cache, looked up under the identity the entry carries. + /// + [Fact] + public void ToLauncherData_RestoresCachedPaletteForEachVersion() + { + var themeCache = new FakeModificationThemeCache(); + var contraTheme = new LauncherContentTheme { GenLauncherActiveColor = "#baff0c" }; + var shockWaveTheme = new LauncherContentTheme { GenLauncherActiveColor = "#00e3ff" }; + themeCache.Save( + new LauncherContentKey(ModificationType.Mod, string.Empty, "Contra", "009"), + contraTheme); + themeCache.Save( + new LauncherContentKey(ModificationType.Mod, string.Empty, "ShockWave", "1.2"), + shockWaveTheme); + var state = new LauncherContentState + { + Modifications = + [ + new() + { + Name = "Contra", + ModificationType = ModificationType.Mod, + ModificationVersions = [new() { Version = "009", Installed = true }] + }, + new() + { + Name = "ShockWave", + ModificationType = ModificationType.Mod, + ModificationVersions = [new() { Version = "1.2", Installed = true }] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state, themeCache); + + launcherData.Modifications + .Select(modification => modification.Versions.Single().Theme) + .Should().Equal(contraTheme, shockWaveTheme); + } + + /// + /// A suspended download has partial content on disk but is neither installed nor selected, so it has to + /// persist on its own merit or the next session would forget it and start over. + /// + [Fact] + public void ToLauncherContentState_PersistsSuspendedDownloadThatIsNeitherInstalledNorSelected() + { + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(TestLauncherContent.Version( + "Contra", + "009", + sourceKind: ContentSourceKind.Manual, + downloadSuspended: true, + suspendedProgressPercentage: 42)); + + var state = LauncherContentStateMapper.ToLauncherContentState(launcherData); + + LauncherContentVersionState version = state.Modifications.Should().ContainSingle().Subject + .ModificationVersions.Should().ContainSingle().Subject; + version.Installed.Should().BeFalse(); + version.IsSelected.Should().BeFalse(); + version.DownloadSuspended.Should().BeTrue(); + version.SuspendedProgressPercentage.Should().Be(42); + } + + [Fact] + public void ToLauncherData_RestoresSuspendedDownloadProgress() + { + var state = new LauncherContentState + { + Modifications = + [ + new() + { + Name = "Contra", + ModificationType = ModificationType.Mod, + ModificationVersions = + [ + new() + { + Version = "009", + DownloadSuspended = true, + SuspendedProgressPercentage = 42, + ContentSourceKind = ContentSourceKind.Manual + } + ] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + LauncherContentInstallation installation = launcherData.Modifications.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject.Installation; + installation.Installed.Should().BeFalse(); + installation.DownloadSuspended.Should().BeTrue(); + installation.SuspendedProgressPercentage.Should().Be(42); + } + + [Fact] + public void ToLauncherData_IgnoresLegacyAdvertisingVersionRecords() + { + var state = new LauncherContentState + { + Modifications = + [ + new() + { + Name = "Featured", + ModificationType = ModificationType.Advertising, + ModificationVersions = + [ + new() + { + Name = "Featured", + Version = "2.0", + ModificationType = ModificationType.Advertising, + Installed = true + } + ] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Modifications.Should().BeEmpty(); + launcherData.Patches.Should().BeEmpty(); + launcherData.Addons.Should().BeEmpty(); + } + + [Fact] + public void ToLauncherContentState_PersistsAddedRepositoryModsButFiltersUninstalledChildren() + { + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new LauncherContentVersion + { + Installation = new LauncherContentInstallation { Installed = true }, + ModificationType = ModificationType.Mod, + Name = "Installed", + Version = "1.0" + }); + launcherData.AddOrUpdate(new LauncherContentVersion + { + Installation = new LauncherContentInstallation + { + ContentSourceKind = ContentSourceKind.ManagedSingleFile + }, + ModificationType = ModificationType.Mod, + Name = "Added", + Version = "2.0" + }); + launcherData.AddOrUpdate(new LauncherContentVersion + { + Installation = new LauncherContentInstallation + { + ContentSourceKind = ContentSourceKind.ManagedSingleFile + }, + ModificationType = ModificationType.Patch, + ParentContentName = "Installed", + Name = "Uninstalled Child", + Version = "1.0" + }); + + var state = LauncherContentStateMapper.ToLauncherContentState(launcherData); + + state.Modifications.Select(entry => entry.Name).Should().Equal("Installed", "Added"); + state.Modifications[0].ModificationVersions.Should().ContainSingle() + .Which.Version.Should().Be("1.0"); + state.Modifications[1].ModificationVersions.Should().ContainSingle() + .Which.Version.Should().Be("2.0"); + state.Patches.Should().BeEmpty(); + } + + [Fact] + public void ToLauncherContentState_DoesNotPersistVersionSelectionForUnselectedEntry() + { + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new LauncherContentVersion + { + Installation = new LauncherContentInstallation { Installed = true, IsSelected = true }, + ModificationType = ModificationType.Mod, + Name = "Installed", + Version = "1.0" + }); + launcherData.Modifications[0].IsSelected = false; + + var state = LauncherContentStateMapper.ToLauncherContentState(launcherData); + + LauncherContentEntryState entry = state.Modifications.Should().ContainSingle().Subject; + entry.IsSelected.Should().BeFalse(); + entry.ModificationVersions.Should().ContainSingle().Which.IsSelected.Should().BeFalse(); + } + + [Fact] + public void ToLauncherContentState_PersistsEntryOrder() + { + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new LauncherContentVersion + { + Installation = new LauncherContentInstallation { Installed = true }, + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0" + }); + launcherData.Modifications[0].NumberInList = 7; + + var state = LauncherContentStateMapper.ToLauncherContentState(launcherData); + + state.Modifications.Should().ContainSingle().Which.NumberInList.Should().Be(7); + } + + /// + /// A stored document can carry an explicit null for a whole section. That reads as "nothing stored here", not + /// as a reason to fail the restore and lose every other section with it. + /// + [Fact] + public void ToLauncherData_MapsNullStoredSectionsToAnEmptyCatalog() + { + var state = new LauncherContentState + { + Modifications = null!, + Addons = null!, + Patches = null! + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Modifications.Should().BeEmpty(); + launcherData.Patches.Should().BeEmpty(); + launcherData.Addons.Should().BeEmpty(); + } + + [Fact] + public void ToLauncherData_MapsAnEntryWithNullVersionRecordsToNoContent() + { + var state = new LauncherContentState + { + Modifications = + [ + new() + { + Name = "ShockWave", + ModificationType = ModificationType.Mod, + ModificationVersions = null! + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Modifications.Should().BeEmpty(); + } + + /// + /// Legacy documents mix advertising records in among ordinary version records. Such a record maps to no + /// content card at all, and that must not cost the entry the order and selection it stored. + /// + [Fact] + public void ToLauncherData_KeepsStoredEntryOrderWhenALegacyAdvertisingRecordFollowsTheContentRecord() + { + var state = new LauncherContentState + { + Modifications = + [ + new() + { + Name = "ShockWave", + ModificationType = ModificationType.Mod, + IsSelected = true, + NumberInList = 7, + ModificationVersions = + [ + new() { Version = "1.0", Installed = true, IsSelected = true }, + new() + { + Name = "Featured", + Version = "2.0", + ModificationType = ModificationType.Advertising, + Installed = true + } + ] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + LauncherContent modification = launcherData.Modifications.Should().ContainSingle().Subject; + modification.NumberInList.Should().Be(7); + modification.IsSelected.Should().BeTrue(); + } + + /// + /// A version record carries its own identity, and it is the record each version is rebuilt from, so the name + /// and parent it declares win over the entry header it happens to be stored under. + /// + [Fact] + public void ToLauncherData_PrefersTheVersionRecordIdentityOverTheEntryHeader() + { + var state = new LauncherContentState + { + Patches = + [ + new() + { + Name = "Stale Entry Name", + DependenceName = "Stale Parent", + ModificationType = ModificationType.Patch, + ModificationVersions = + [ + new() + { + Name = "Balance Patch", + Version = "2.0", + DependenceName = "ShockWave", + Installed = true + } + ] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + LauncherContentVersion version = launcherData.Patches.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject; + version.Name.Should().Be("Balance Patch"); + version.ParentContentName.Should().Be("ShockWave"); + } + + /// + /// Any stored text key may be an explicit null. Identity text is what the catalog keys content by, so a null + /// has to restore as empty text rather than travel into a content key. + /// + [Fact] + public void ToLauncherData_MapsNullStoredIdentityTextToEmptyValues() + { + var state = new LauncherContentState + { + Modifications = + [ + new() + { + Name = null!, + DependenceName = null!, + ModificationType = ModificationType.Mod, + ModificationVersions = [new() { Version = null!, Installed = true }] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + LauncherContentVersion version = launcherData.Modifications.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject; + version.Name.Should().BeEmpty(); + version.Version.Should().BeEmpty(); + version.ParentContentName.Should().BeEmpty(); + } + + /// + /// A record that never stored a version restores under the version-less identity, which is the identity the + /// mod folder itself is keyed by. + /// + [Fact] + public void ToLauncherData_RestoresARecordWithNoStoredVersionUnderTheVersionlessIdentity() + { + var state = new LauncherContentState + { + Modifications = + [ + new() + { + Name = "ShockWave", + ModificationType = ModificationType.Mod, + ModificationVersions = [new() { Installed = true }] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Modifications.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject + .Version.Should().BeEmpty(); + } + + /// + /// Content type is settled by the version record first, then the entry header, then the section the entry is + /// stored in. A record that declares a type of its own therefore keeps it even when the section disagrees. + /// + [Fact] + public void ToLauncherData_UsesTheVersionRecordTypeWhenItDisagreesWithTheStoredSection() + { + var state = new LauncherContentState + { + Patches = + [ + new() + { + Name = "HD Textures", + DependenceName = "ShockWave", + ModificationType = ModificationType.Patch, + ModificationVersions = + [ + new() { Version = "1.0", ModificationType = ModificationType.Addon, Installed = true } + ] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Patches.Should().BeEmpty(); + launcherData.Addons.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject + .ModificationType.Should().Be(ModificationType.Addon); + } + + /// + /// When the version record declares no type of its own, the entry header settles it, and the header still + /// outranks the section the entry is stored in. + /// + [Fact] + public void ToLauncherData_UsesTheEntryHeaderTypeWhenTheVersionRecordDeclaresNoneAndTheSectionDisagrees() + { + var state = new LauncherContentState + { + Patches = + [ + new() + { + Name = "HD Textures", + DependenceName = "ShockWave", + ModificationType = ModificationType.Addon, + ModificationVersions = [new() { Version = "1.0", Installed = true }] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Patches.Should().BeEmpty(); + launcherData.Addons.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject + .ModificationType.Should().Be(ModificationType.Addon); + } + + /// + /// Each persisted version record carries its own identity and type, because that record — not the entry + /// header — is what the next start rebuilds the version from. + /// + [Fact] + public void ToLauncherContentState_PersistsEachVersionRecordWithItsOwnIdentityAndType() + { + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(TestLauncherContent.Version( + "Balance Patch", + "2.0", + ModificationType.Patch, + "ShockWave", + installed: true)); + + var state = LauncherContentStateMapper.ToLauncherContentState(launcherData); + + LauncherContentEntryState entry = state.Patches.Should().ContainSingle().Subject; + entry.ModificationType.Should().Be(ModificationType.Patch); + LauncherContentVersionState version = entry.ModificationVersions.Should().ContainSingle().Subject; + version.Name.Should().Be("Balance Patch"); + version.Version.Should().Be("2.0"); + version.DependenceName.Should().Be("ShockWave"); + version.ModificationType.Should().Be(ModificationType.Patch); + } + + private static LauncherContentEntryState CreateEntry( + string name, + string parentContentName, + ModificationType contentType, + string version, + bool selected, + bool? versionSelected = null, + int numberInList = 4) + { + return new LauncherContentEntryState + { + Name = name, + DependenceName = parentContentName, + ModificationType = contentType, + IsSelected = selected, + NumberInList = numberInList, + ModificationVersions = + [ + new() + { + 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..56d32a1d --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherLocalContentReconcilerTests.cs @@ -0,0 +1,271 @@ +using System.Collections; +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherLocalContentReconcilerTests +{ + [Fact] + public void Reconcile_AddsUnregisteredLocalVersions() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + var launcherData = new LauncherData(); + localContentService.InstalledVersions = + [ + TestLauncherContent.Version("Local Only", "1.0", installed: true) + ]; + + reconciler.Reconcile(launcherData, new List(), paths); + + launcherData.Modifications.Should().ContainSingle(mod => mod.Name == "Local Only"); + localContentService.EmptyPackageBackupCleanupRequests.Should().ContainSingle().Which.Should().Be(paths); + } + + [Fact] + public void Reconcile_MarksMissingRemoteVersionsUninstalledAndDeletesMissingLocalOnlyVersions() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + LauncherContentVersion remoteVersion = TestLauncherContent.Version("Remote", "1.0", installed: true); + LauncherContentVersion localOnlyVersion = TestLauncherContent.Version("Local Only", "2.0", installed: true); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(remoteVersion); + launcherData.AddOrUpdate(localOnlyVersion); + localContentService.InstalledVersions = []; + + reconciler.Reconcile( + launcherData, + new List { remoteVersion.ContentKey }, + paths); + + launcherData.Modifications.Should().ContainSingle().Which.Name.Should().Be("Remote"); + launcherData.Modifications[0].Versions.Should().ContainSingle().Which.Installation.Installed.Should().BeFalse(); + localContentService.ImageDeletionRequests.Should().ContainSingle(request => + request.Paths == paths && + request.ContentKey.Name == "Local Only" && + ReferenceEquals(request.Data, launcherData)); + } + + [Fact] + public void Reconcile_PreservesAddedRepositoryModWhenRemoteCatalogIsUnavailable() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var localContentService = new RecordingLocalLauncherContentService + { + InstalledVersions = [] + }; + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + LauncherContentVersion addedVersion = TestLauncherContent.Version( + "Added", + "1.0", + sourceKind: ContentSourceKind.ManagedSingleFile); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(addedVersion); + + reconciler.Reconcile(launcherData, [], paths); + + launcherData.Modifications.Should().ContainSingle() + .Which.Name.Should().Be("Added"); + localContentService.ImageDeletionRequests.Should().BeEmpty(); + } + + [Fact] + public void Reconcile_MarksStaleOriginalGameAddonUninstalled() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + LauncherContentVersion addon = TestLauncherContent.Version( + "Original Game Addon", + "1.0", + ModificationType.Addon, + LauncherContentKey.OriginalGame.Name, + installed: true); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(addon); + + reconciler.Reconcile(launcherData, new[] { addon.ContentKey }, paths); + + launcherData.Addons.Should().ContainSingle(); + addon.Installation.Installed.Should().BeFalse(); + } + + [Fact] + public void Reconcile_MarksStaleOriginalGamePatchUninstalled() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + LauncherContentVersion patch = TestLauncherContent.Version( + "Original Game Patch", + "1.0", + ModificationType.Patch, + LauncherContentKey.OriginalGame.Name, + installed: true); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(patch); + + reconciler.Reconcile(launcherData, new[] { patch.ContentKey }, paths); + + launcherData.Patches.Should().ContainSingle(); + patch.Installation.Installed.Should().BeFalse(); + } + + [Fact] + public void Reconcile_ChecksAChildSharedByMultipleParentVersionsOnce() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + LauncherContentVersion firstParent = TestLauncherContent.Version("Parent", "1.0"); + LauncherContentVersion secondParent = TestLauncherContent.Version("Parent", "2.0"); + LauncherContentVersion child = TestLauncherContent.Version( + "Shared Child", + "1.0", + ModificationType.Addon, + "Parent", + installed: true); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(firstParent); + launcherData.AddOrUpdate(secondParent); + launcherData.AddOrUpdate(child); + localContentService.InstalledVersions = [firstParent, secondParent]; + var downloadedContent = + new EnumerationCountingReadOnlyCollection([child.ContentKey]); + + reconciler.Reconcile(launcherData, downloadedContent, paths); + + child.Installation.Installed.Should().BeFalse(); + downloadedContent.EnumerationCount.Should().Be(1); + } + + [Theory] + [InlineData("First Patch")] + [InlineData("Second Patch")] + public void Reconcile_IsIndependentOfTheGloballySelectedPatch(string selectedPatchName) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + LauncherContentVersion parent = TestLauncherContent.Version("Parent", "1.0", isSelected: true); + LauncherContentVersion firstPatch = TestLauncherContent.Version( + "First Patch", + "1.0", + ModificationType.Patch, + parent.Name, + isSelected: selectedPatchName == "First Patch"); + LauncherContentVersion secondPatch = TestLauncherContent.Version( + "Second Patch", + "1.0", + ModificationType.Patch, + parent.Name, + isSelected: selectedPatchName == "Second Patch"); + LauncherContentVersion firstAddon = TestLauncherContent.Version( + "First Addon", + "1.0", + ModificationType.Addon, + firstPatch.Name, + installed: true); + LauncherContentVersion secondAddon = TestLauncherContent.Version( + "Second Addon", + "1.0", + ModificationType.Addon, + secondPatch.Name, + installed: true); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(parent); + launcherData.AddOrUpdate(firstPatch); + launcherData.AddOrUpdate(secondPatch); + launcherData.AddOrUpdate(firstAddon); + launcherData.AddOrUpdate(secondAddon); + localContentService.InstalledVersions = [parent, firstPatch, secondPatch]; + + reconciler.Reconcile( + launcherData, + new[] { firstAddon.ContentKey, secondAddon.ContentKey }, + paths); + + firstAddon.Installation.Installed.Should().BeFalse(); + secondAddon.Installation.Installed.Should().BeFalse(); + } + + /// + /// Discarding a version drops it from the catalog as well as from disk, and asks for its cached artwork to be + /// removed once nothing else refers to the card. A version the user threw away must not come back as a card + /// entry the next time the list is drawn. + /// + [Fact] + public void DiscardVersion_RemovesTheVersionFromTheCatalogAndFromDisk() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + LauncherContentVersion discardedVersion = TestLauncherContent.Version("ShockWave", "1.0", installed: true); + LauncherContentVersion keptVersion = TestLauncherContent.Version("ShockWave", "2.0", installed: true); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(discardedVersion); + launcherData.AddOrUpdate(keptVersion); + + reconciler.DiscardVersion(launcherData, discardedVersion.ContentKey, paths); + + launcherData.Modifications.Should().ContainSingle() + .Which.Versions.Should().ContainSingle() + .Which.Version.Should().Be("2.0"); + localContentService.DeletedVersions.Should().ContainSingle(request => + request.Paths == paths && + request.ContentKey == discardedVersion.ContentKey); + localContentService.ImageDeletionRequests.Should().ContainSingle(request => + request.ContentKey == discardedVersion.ContentKey && + ReferenceEquals(request.Data, launcherData)); + } + + private static LauncherLocalContentReconciler CreateReconciler( + ILocalLauncherContentService localContentService) + { + return new LauncherLocalContentReconciler( + localContentService, + NullLogger.Instance); + } + + private sealed class EnumerationCountingReadOnlyCollection : IReadOnlyCollection + { + private readonly IReadOnlyCollection _items; + + public EnumerationCountingReadOnlyCollection(IReadOnlyCollection items) + { + _items = items; + } + + public int EnumerationCount { get; private set; } + + public int Count => _items.Count; + + public IEnumerator GetEnumerator() + { + EnumerationCount++; + return _items.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/RemoteLauncherCatalogClientTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/RemoteLauncherCatalogClientTests.cs new file mode 100644 index 00000000..ea71506e --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/RemoteLauncherCatalogClientTests.cs @@ -0,0 +1,418 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +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 DownloadInstalledModDataAsync_ReadsInstalledModsAndPreservesPartialFailuresAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + 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()); + yamlReader.SetResult(shockwaveUri, new LegacyContentManifest + { + Name = "ShockWave", + Version = "1.2" + }); + yamlReader.SetException( + brokenUri, + new InvalidOperationException("Broken manifest")); + + IReadOnlyList result = + await client.DownloadInstalledModDataAsync( + catalog, + new[] { "shockwave", "broken" }, + CancellationToken.None); + + RemoteModificationManifest entry = result.Should().ContainSingle().Subject; + entry.Content.Name.Should().Be("ShockWave"); + entry.Content.Version.Should().Be("1.2"); + entry.PatchManifestUrls.Should().BeEmpty(); + yamlReader.GetReadCount(contraUri).Should().Be(0); + } + + /// + /// The backend publishes unnamed references for content the launcher cannot match against installed folders, + /// so they are read on every refresh instead of being filtered out with the mods nobody installed. + /// + [Fact] + public async Task DownloadInstalledModDataAsync_ReadsUnnamedReferenceRegardlessOfInstalledContentAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var unnamedUri = new Uri("https://example.test/unnamed.yaml"); + var contraUri = new Uri("https://example.test/contra.yaml"); + var catalog = new RemoteLauncherCatalog( + Array.Empty(), + new List + { + new(string.Empty, unnamedUri.ToString(), Array.Empty(), Array.Empty()), + new("Contra", contraUri.ToString(), Array.Empty(), Array.Empty()) + }, + Array.Empty(), + Array.Empty()); + yamlReader.SetResult(unnamedUri, new LegacyContentManifest + { + Name = "Community Pack", + Version = "3.0" + }); + + IReadOnlyList result = + await client.DownloadInstalledModDataAsync( + catalog, + new[] { "ShockWave" }, + CancellationToken.None); + + result.Should().ContainSingle().Which.Content.Name.Should().Be("Community Pack"); + yamlReader.GetReadCount(contraUri).Should().Be(0); + } + + [Fact] + public async Task ReadChildManifestsAsync_ReturnsSuccessfulChildrenWhenOneChildFailsAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + 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.SetResult(patchUri, new LegacyContentManifest + { + Name = "Patch", + Version = "1.0" + }); + yamlReader.SetException( + missingUri, + new InvalidOperationException("Missing manifest")); + + RemoteChildManifestLoadResult result = await client.ReadChildManifestsAsync( + new[] { patchUri.ToString(), missingUri.ToString() }, + null, + CancellationToken.None); + + result.ContentVersions.Should().ContainSingle().Which.Name.Should().Be("Patch"); + result.FailedCount.Should().Be(1); + result.Succeeded.Should().BeFalse(); + } + + [Fact] + public async Task ReadCatalogAsync_MapsThirdPartyManifestToNormalizedCatalogAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var manifestUri = new Uri("https://example.test/repos.yaml"); + yamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + AdvData = + { + new LegacyCatalogAdvertisingReference + { + ModName = "Featured", + ModLink = "https://example.test/featured.yaml", + ImagesData = { "https://cdn.example.test/featured.png" } + } + }, + modDatas = + { + new LegacyCatalogModificationReference + { + 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" + }); + + RemoteLauncherCatalog catalog = await client.ReadCatalogAsync(manifestUri, CancellationToken.None); + + 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"); + } + + [Fact] + public void GetModificationNames_ReturnsCatalogModificationNames() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var catalog = new RemoteLauncherCatalog( + Array.Empty(), + new List + { + new("ShockWave", "https://example.test/shockwave.yaml", Array.Empty(), Array.Empty()), + new("Contra", "https://example.test/contra.yaml", Array.Empty(), Array.Empty()) + }, + Array.Empty(), + Array.Empty()); + + IReadOnlyList names = client.GetModificationNames(catalog); + + names.Should().Equal("ShockWave", "Contra"); + } + + [Fact] + public async Task DownloadModDataByNameAsync_ReadsReferenceCaseInsensitivelyAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var shockwaveUri = new Uri("https://example.test/shockwave.yaml"); + string patchUrl = "https://example.test/shockwave-patch.yaml"; + string addonUrl = "https://example.test/shockwave-addon.yaml"; + var catalog = new RemoteLauncherCatalog( + Array.Empty(), + new List + { + new("ShockWave", shockwaveUri.ToString(), new[] { patchUrl }, new[] { addonUrl }) + }, + Array.Empty(), + Array.Empty()); + yamlReader.SetResult(shockwaveUri, new LegacyContentManifest + { + Name = "ShockWave", + Version = "1.2" + }); + + RemoteModificationManifest result = await client.DownloadModDataByNameAsync( + catalog, + "shockwave", + CancellationToken.None); + + result.Content.Name.Should().Be("ShockWave"); + result.Content.Version.Should().Be("1.2"); + result.PatchManifestUrls.Should().ContainSingle(patchUrl); + result.AddonManifestUrls.Should().ContainSingle(addonUrl); + } + + [Fact] + public async Task DownloadAdvertisingInfoAsync_ReturnsManifestWhenReadSucceedsAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var manifestUri = new Uri("https://example.test/featured.yaml"); + yamlReader.SetResult(manifestUri, new LegacyContentManifest + { + Name = "Featured", + Version = "2.0", + UIImageSourceLink = "https://cdn.example.test/featured.png" + }); + + LauncherContentVersion? result = await client.DownloadAdvertisingInfoAsync( + manifestUri.ToString(), + CancellationToken.None); + + result.Should().NotBeNull(); + result!.Name.Should().Be("Featured"); + result.Version.Should().Be("2.0"); + result.UIImageSourceLink.Should().Be("https://cdn.example.test/featured.png"); + } + + [Fact] + public async Task DownloadAdvertisingInfoAsync_ReturnsNullWhenReadFailsAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var manifestUri = new Uri("https://example.test/featured.yaml"); + yamlReader.SetException( + manifestUri, + new InvalidOperationException("Missing manifest.")); + + LauncherContentVersion? result = await client.DownloadAdvertisingInfoAsync( + manifestUri.ToString(), + CancellationToken.None); + + result.Should().BeNull(); + } + + /// + /// A manifest read that fails is tolerated and reported as absent content, but a cancelled one is not a + /// failure of the manifest: swallowing it would let a torn-down session go on to publish a catalog assembled + /// from whatever happened to arrive first. + /// + [Fact] + public async Task DownloadInstalledModDataAsync_PropagatesCancellationRaisedWhileReadingAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var shockwaveUri = new Uri("https://example.test/shockwave.yaml"); + var catalog = new RemoteLauncherCatalog( + Array.Empty(), + new List + { + new("ShockWave", shockwaveUri.ToString(), Array.Empty(), Array.Empty()) + }, + Array.Empty(), + Array.Empty()); + using var cancellation = new CancellationTokenSource(); + yamlReader.SetHandler(shockwaveUri, (_, _) => + { + cancellation.Cancel(); + return Task.FromCanceled(cancellation.Token); + }); + + Func act = () => client.DownloadInstalledModDataAsync( + catalog, + new[] { "ShockWave" }, + cancellation.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReadChildManifestsAsync_PropagatesCancellationRaisedWhileReadingAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var patchUri = new Uri("https://example.test/patch.yaml"); + using var cancellation = new CancellationTokenSource(); + yamlReader.SetHandler(patchUri, (_, _) => + { + cancellation.Cancel(); + return Task.FromCanceled(cancellation.Token); + }); + + Func act = () => client.ReadChildManifestsAsync( + new[] { patchUri.ToString() }, + null, + cancellation.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DownloadAdvertisingInfoAsync_PropagatesCancellationRaisedWhileReadingAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var manifestUri = new Uri("https://example.test/featured.yaml"); + using var cancellation = new CancellationTokenSource(); + yamlReader.SetHandler(manifestUri, (_, _) => + { + cancellation.Cancel(); + return Task.FromCanceled(cancellation.Token); + }); + + Func act = () => client.DownloadAdvertisingInfoAsync( + manifestUri.ToString(), + cancellation.Token); + + await act.Should().ThrowAsync(); + } + + /// + /// Manifest reads are capped at a handful in flight so a refresh does not flood the backend. The cap bounds + /// how many run at once, not how many are read: a catalog listing more than the cap still gets read whole. + /// + [Fact] + public async Task ReadChildManifestsAsync_ReadsEveryManifestBeyondItsConcurrencyLimitAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var manifestUrls = new List(); + for (int index = 0; index < 12; index++) + { + var childUri = new Uri($"https://example.test/patch-{index}.yaml"); + yamlReader.SetResult(childUri, new LegacyContentManifest + { + ModificationType = ModificationType.Patch, + Name = $"Patch {index}", + Version = "1.0" + }); + manifestUrls.Add(childUri.ToString()); + } + + RemoteChildManifestLoadResult result = await client.ReadChildManifestsAsync( + manifestUrls, + "ShockWave", + CancellationToken.None); + + result.ContentVersions.Should().HaveCount(12); + result.Succeeded.Should().BeTrue(); + } + + [Fact] + public async Task DownloadInstalledModDataAsync_ReadsEveryManifestBeyondItsConcurrencyLimitAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var references = new List(); + var installedModNames = new List(); + for (int index = 0; index < 12; index++) + { + var modUri = new Uri($"https://example.test/mod-{index}.yaml"); + yamlReader.SetResult(modUri, new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = $"Mod {index}", + Version = "1.0" + }); + references.Add(new RemoteCatalogModificationReference( + $"Mod {index}", + modUri.ToString(), + Array.Empty(), + Array.Empty())); + installedModNames.Add($"Mod {index}"); + } + + IReadOnlyList result = await client.DownloadInstalledModDataAsync( + new RemoteLauncherCatalog( + Array.Empty(), + references, + Array.Empty(), + Array.Empty()), + installedModNames, + CancellationToken.None); + + result.Should().HaveCount(12); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/YamlLauncherContentStateStoreTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/YamlLauncherContentStateStoreTests.cs new file mode 100644 index 00000000..8bdcbe96 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/YamlLauncherContentStateStoreTests.cs @@ -0,0 +1,242 @@ +using System.IO; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Infrastructure.Persistence.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class YamlLauncherContentStateStoreTests +{ + [Theory] + [InlineData("Mod", 0)] + [InlineData("Addon", 1)] + [InlineData("Patch", 2)] + [InlineData("Advertising", 3)] + public void Load_AcceptsLegacyPersistedContentTypeNames( + string persistedValue, + int expectedTypeValue) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string documentPath = paths.LauncherDataFilePath; + Directory.CreateDirectory(Path.GetDirectoryName(documentPath)!); + File.WriteAllText( + documentPath, + $""" + Addons: [] + Modifications: + - ModificationType: {persistedValue} + Name: Compatibility Entry + DependenceName: Original Game + Installed: true + IsSelected: false + NumberInList: 0 + ModificationVersions: + - ModificationType: {persistedValue} + Name: Compatibility Entry + Version: 1.0 + DependenceName: Original Game + Installed: true + IsSelected: false + ContentSourceKind: Manual + Patches: [] + """); + var store = new YamlLauncherContentStateStore( + new AtomicFileWriter(), + NullLogger>.Instance); + + LauncherContentState state = store.Load(paths); + + LauncherContentEntryState entry = state.Modifications.Should().ContainSingle().Subject; + entry.ModificationType.Should().Be((ModificationType)expectedTypeValue); + entry.DependenceName.Should().Be("Original Game"); + LauncherContentVersionState version = entry.ModificationVersions.Should().ContainSingle().Subject; + version.ModificationType.Should().Be((ModificationType)expectedTypeValue); + version.Name.Should().Be("Compatibility Entry"); + version.Version.Should().Be("1.0"); + version.DependenceName.Should().Be("Original Game"); + } + + [Theory] + [InlineData(0, "Mod")] + [InlineData(1, "Addon")] + [InlineData(2, "Patch")] + [InlineData(3, "Advertising")] + public void Save_PreservesLegacyPersistedContentTypeNames( + int contentTypeValue, + string persistedValue) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string documentPath = paths.LauncherDataFilePath; + var store = new YamlLauncherContentStateStore( + new AtomicFileWriter(), + NullLogger>.Instance); + var state = new LauncherContentState + { + Modifications = + { + new LauncherContentEntryState + { + ModificationType = (ModificationType)contentTypeValue, + Name = "Compatibility Entry", + DependenceName = "Original Game", + ModificationVersions = + { + new LauncherContentVersionState + { + ModificationType = (ModificationType)contentTypeValue, + Name = "Compatibility Entry", + Version = "1.0", + DependenceName = "Original Game" + } + } + } + } + }; + + store.Save(paths, state); + + string yaml = File.ReadAllText(documentPath); + yaml.Should().Contain($"ModificationType: {persistedValue}", Exactly.Twice()); + yaml.Should().Contain("DependenceName: Original Game", Exactly.Twice()); + } + + [Fact] + public void Load_UsesEmptyContentStateAsDefaultDocument() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + var store = new YamlLauncherContentStateStore( + new AtomicFileWriter(), + NullLogger>.Instance); + + LauncherContentState state = store.Load(paths); + + state.Modifications.Should().BeEmpty(); + state.Addons.Should().BeEmpty(); + state.Patches.Should().BeEmpty(); + } + + [Fact] + public void Save_KeepsIdenticalContentKeysIsolatedByGame() + { + using var directory = new TestDirectory(); + LauncherPaths generalsPaths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + LauncherPaths zeroHourPaths = TestLauncherPaths.Create(directory); + var store = new YamlLauncherContentStateStore( + new AtomicFileWriter(), + NullLogger>.Instance); + LauncherContentState generalsState = CreateState("Shared Key", "Generals Version"); + LauncherContentState zeroHourState = CreateState("Shared Key", "Zero Hour Version"); + + store.Save(generalsPaths, generalsState); + store.Save(zeroHourPaths, zeroHourState); + + store.Load(generalsPaths).Modifications.Should().ContainSingle() + .Which.ModificationVersions.Should().ContainSingle() + .Which.Version.Should().Be("Generals Version"); + store.Load(zeroHourPaths).Modifications.Should().ContainSingle() + .Which.ModificationVersions.Should().ContainSingle() + .Which.Version.Should().Be("Zero Hour Version"); + generalsPaths.LauncherDataFilePath.Should().NotBe(zeroHourPaths.LauncherDataFilePath); + } + + [Fact] + public void Save_PreservesLauncherContentYamlKeysAndEnumValues() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string documentPath = paths.LauncherDataFilePath; + var store = new YamlLauncherContentStateStore( + new AtomicFileWriter(), + NullLogger>.Instance); + var state = new LauncherContentState + { + Modifications = + { + new LauncherContentEntryState + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + DependenceName = "Original game", + Installed = true, + IsSelected = true, + NumberInList = 3, + ModificationVersions = + { + new LauncherContentVersionState + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2", + DependenceName = "Original game", + Installed = true, + IsSelected = true, + DownloadSuspended = true, + SuspendedProgressPercentage = 42.5, + ContentSourceKind = ContentSourceKind.Manual + } + } + } + } + }; + + store.Save(paths, state); + + string yaml = File.ReadAllText(documentPath); + yaml.Should().Contain("Addons:"); + yaml.Should().Contain("Modifications:"); + yaml.Should().Contain("Patches:"); + yaml.Should().Contain("ModificationType: Mod"); + yaml.Should().Contain("Name: ShockWave"); + yaml.Should().Contain("Version: 1.2"); + yaml.Should().Contain("DependenceName: Original game"); + yaml.Should().Contain("Installed: true"); + yaml.Should().Contain("IsSelected: true"); + yaml.Should().Contain("NumberInList: 3"); + yaml.Should().Contain("ModificationVersions:"); + yaml.Should().Contain("DownloadSuspended: true"); + yaml.Should().Contain("SuspendedProgressPercentage: 42.5"); + yaml.Should().Contain("ContentSourceKind: Manual"); + + LauncherContentState loadedState = store.Load(paths); + LauncherContentEntryState loadedEntry = loadedState.Modifications.Should().ContainSingle().Subject; + loadedEntry.ModificationType.Should().Be(ModificationType.Mod); + loadedEntry.NumberInList.Should().Be(3); + LauncherContentVersionState loadedVersion = + loadedEntry.ModificationVersions.Should().ContainSingle().Subject; + loadedVersion.Version.Should().Be("1.2"); + loadedVersion.DownloadSuspended.Should().BeTrue(); + loadedVersion.SuspendedProgressPercentage.Should().Be(42.5); + loadedVersion.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + } + + private static LauncherContentState CreateState(string name, string version) + { + return new LauncherContentState + { + Modifications = + { + new LauncherContentEntryState + { + ModificationType = ModificationType.Mod, + Name = name, + ModificationVersions = + { + new LauncherContentVersionState + { + ModificationType = ModificationType.Mod, + Name = name, + Version = version + } + } + } + } + }; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Support/RemoteLauncherCatalogMapperTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Support/RemoteLauncherCatalogMapperTests.cs new file mode 100644 index 00000000..38c80674 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Support/RemoteLauncherCatalogMapperTests.cs @@ -0,0 +1,420 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Support; +using GenLauncherGO.Infrastructure.Remote; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Support; + +public sealed class RemoteLauncherCatalogMapperTests +{ + [Fact] + public async Task PublishedCatalogYaml_MapsExactLegacyShapeAndLeavesVestigialGlobalAddonsUnpublishedAsync() + { + const string GlobalAddonUrl = "https://example.test/global-addon.yaml"; + LegacyLauncherCatalogDocument document = await ReadRemoteYamlAsync( + """ + AdvData: + - ModName: Featured + ModLink: https://example.test/featured.yaml + ImagesData: + - https://cdn.example.test/featured-1.png + - https://cdn.example.test/featured-2.png + modDatas: + - ModName: ShockWave + ModLink: https://example.test/shockwave.yaml + ModPatches: + - https://example.test/shockwave-patch.yaml + - ~ + ModAddons: + - https://example.test/shockwave-addon.yaml + - ModName: Contra + ModLink: https://example.test/contra.yaml + globalAddonsData: + - https://example.test/global-addon.yaml + originalGameAddons: + - https://example.test/original-addon.yaml + - + originalGamePatches: + - https://example.test/original-patch.yaml + LauncherVersion: 1.2.3 + """); + + document.globalAddonsData.Should().Equal(GlobalAddonUrl); + document.modDatas.Should().HaveCount(2); + document.modDatas[0].ModPatches.Should().HaveCount(2) + .And.Contain(url => url == null); + document.originalGameAddons.Should().HaveCount(2) + .And.Contain(url => url == null); + LegacyCatalogModificationReference defaultedReference = document.modDatas[1]; + defaultedReference.ModPatches.Should().BeEmpty(); + defaultedReference.ModAddons.Should().BeEmpty(); + + RemoteLauncherCatalog catalog = RemoteLauncherCatalogMapper.ToRemoteCatalog(document); + + RemoteAdvertisingReference advertising = catalog.AdvertisingEntries.Should().ContainSingle().Subject; + advertising.Name.Should().Be("Featured"); + advertising.ManifestUrl.Should().Be("https://example.test/featured.yaml"); + advertising.ImageUrls.Should().Equal( + "https://cdn.example.test/featured-1.png", + "https://cdn.example.test/featured-2.png"); + + RemoteCatalogModificationReference modification = catalog.Modifications[0]; + modification.Name.Should().Be("ShockWave"); + modification.ManifestUrl.Should().Be("https://example.test/shockwave.yaml"); + modification.PatchManifestUrls.Should().Equal("https://example.test/shockwave-patch.yaml"); + modification.AddonManifestUrls.Should().Equal("https://example.test/shockwave-addon.yaml"); + catalog.Modifications[1].Name.Should().Be("Contra"); + catalog.Modifications[1].PatchManifestUrls.Should().BeEmpty(); + catalog.Modifications[1].AddonManifestUrls.Should().BeEmpty(); + catalog.OriginalGameAddonManifestUrls.Should().Equal( + "https://example.test/original-addon.yaml"); + catalog.OriginalGamePatchManifestUrls.Should().Equal( + "https://example.test/original-patch.yaml"); + + catalog.Modifications.SelectMany(entry => entry.AddonManifestUrls) + .Should().NotContain(GlobalAddonUrl); + catalog.OriginalGameAddonManifestUrls.Should().NotContain(GlobalAddonUrl); + } + + [Fact] + public async Task PublishedContentYaml_MapsSupportedFieldsAndThemeBlockAsync() + { + LegacyContentManifest document = await ReadRemoteYamlAsync( + """ + ModificationType: Patch + Name: ShockWave Patch + Version: '2.4' + SimpleDownloadLink: https://downloads.example.test/shockwave-patch.zip + UIImageSourceLink: https://cdn.example.test/shockwave-patch.png + DiscordLink: https://discord.example.test/shockwave + ModDBLink: https://moddb.example.test/shockwave + NewsLink: https://news.example.test/shockwave + DependenceName: ShockWave + S3HostLink: https://s3.example.test + S3BucketName: launcher-content + S3FolderName: shockwave/patch + S3HostPublicKey: public-key + S3HostSecretKey: secret-key + NetworkInfo: Multiplayer requires the community service. + Deprecated: true + SupportLink: https://support.example.test/shockwave + ColorsInformation: + GenLauncherActiveColor: '#102030' + GenLauncherBackgroundImageLink: https://cdn.example.test/background.png + ContentSourceKind: Manual + """); + + var version = RemoteLauncherCatalogMapper.ToLauncherContentVersion(document); + + version.Should().BeEquivalentTo(new LauncherContentVersion + { + Installation = new LauncherContentInstallation { ContentSourceKind = ContentSourceKind.ManagedS3 }, + ModificationType = ModificationType.Patch, + Name = "ShockWave Patch", + Version = "2.4", + SimpleDownloadLink = "https://downloads.example.test/shockwave-patch.zip", + UIImageSourceLink = "https://cdn.example.test/shockwave-patch.png", + DiscordLink = "https://discord.example.test/shockwave", + ModDBLink = "https://moddb.example.test/shockwave", + NewsLink = "https://news.example.test/shockwave", + ParentContentName = "ShockWave", + S3HostLink = "https://s3.example.test", + S3BucketName = "launcher-content", + S3FolderName = "shockwave/patch", + S3HostPublicKey = "public-key", + S3HostSecretKey = "secret-key", + NetworkInfo = "Multiplayer requires the community service.", + Deprecated = true, + SupportLink = "https://support.example.test/shockwave", + Theme = new LauncherContentTheme + { + GenLauncherActiveColor = "#102030", + GenLauncherBackgroundImageLink = "https://cdn.example.test/background.png" + } + }); + } + + /// + /// The backend publishes a key with an explicit null whenever it has nothing for it. Every consumer of a + /// mapped version reads these as plain text, so a published null has to arrive as empty text and never as a + /// null that would fault the first caller to touch it. + /// + [Fact] + public async Task PublishedContentYaml_MapsExplicitlyNullTextKeysToEmptyValuesAsync() + { + LegacyContentManifest document = await ReadRemoteYamlAsync( + """ + Name: ~ + Version: ~ + SimpleDownloadLink: ~ + UIImageSourceLink: ~ + DiscordLink: ~ + ModDBLink: ~ + NewsLink: ~ + DependenceName: ~ + S3HostLink: ~ + S3BucketName: ~ + S3FolderName: ~ + S3HostPublicKey: ~ + S3HostSecretKey: ~ + NetworkInfo: ~ + SupportLink: ~ + """); + + var version = RemoteLauncherCatalogMapper.ToLauncherContentVersion(document); + + version.Name.Should().BeEmpty(); + version.Version.Should().BeEmpty(); + version.SimpleDownloadLink.Should().BeEmpty(); + version.UIImageSourceLink.Should().BeEmpty(); + version.DiscordLink.Should().BeEmpty(); + version.ModDBLink.Should().BeEmpty(); + version.NewsLink.Should().BeEmpty(); + version.ParentContentName.Should().BeEmpty(); + version.S3HostLink.Should().BeEmpty(); + version.S3BucketName.Should().BeEmpty(); + version.S3FolderName.Should().BeEmpty(); + version.S3HostPublicKey.Should().BeEmpty(); + version.S3HostSecretKey.Should().BeEmpty(); + version.NetworkInfo.Should().BeEmpty(); + version.SupportLink.Should().BeEmpty(); + } + + /// + /// An explicitly null palette slot means the same as an undeclared one: the launcher fills it from the active + /// game's palette instead of carrying a null colour into presentation. + /// + [Fact] + public async Task PublishedContentTheme_MapsAnExplicitlyNullSlotToEmptyTextAsync() + { + LegacyContentManifest document = await ReadRemoteYamlAsync( + """ + Name: Contra + ColorsInformation: + GenLauncherActiveColor: '#baff0c' + GenLauncherBorderColor: ~ + """); + + LauncherContentTheme? theme = RemoteLauncherCatalogMapper.ToLauncherContentVersion(document).Theme; + + theme.Should().NotBeNull(); + theme!.GenLauncherBorderColor.Should().BeEmpty(); + theme.GenLauncherActiveColor.Should().Be("#baff0c"); + } + + /// + /// A catalog reference may publish null for its name or its link. The name decides whether the reference is + /// matched against installed content and the link is handed to , so neither may arrive null. + /// + [Fact] + public async Task PublishedCatalogYaml_MapsAnExplicitlyNullModificationReferenceToEmptyTextAsync() + { + LegacyLauncherCatalogDocument document = await ReadRemoteYamlAsync( + """ + modDatas: + - ModName: ~ + ModLink: ~ + """); + + RemoteCatalogModificationReference reference = RemoteLauncherCatalogMapper.ToRemoteCatalog(document) + .Modifications.Should().ContainSingle().Subject; + + reference.Name.Should().BeEmpty(); + reference.ManifestUrl.Should().BeEmpty(); + } + + /// + /// Modifications publish whichever slots they care about, so a partial block has to survive mapping intact. + /// + [Fact] + public async Task PublishedContentYaml_KeepsEveryThemeSlotItDeclaresAsync() + { + LegacyContentManifest document = await ReadRemoteYamlAsync( + """ + Name: Contra + ColorsInformation: + GenLauncherBorderColor: '#00e3ff' + GenLauncherInactiveBorder: DarkGray + GenLauncherInactiveBorder2: '#7a7db0' + GenLauncherActiveColor: '#baff0c' + GenLauncherDarkFillColor: '#232977' + GenLauncherDarkBackGround: '#090502' + GenLauncherLightBackGround: '#B3000000' + GenLauncherDefaultTextColor: White + GenLauncherDownloadTextColor: '#090502' + GenLauncherListBoxSelectionColor1: '#E61d2057' + GenLauncherListBoxSelectionColor2: '#F21d2057' + GenLauncherButtonSelectionColor: '#2534ff' + GenLauncherBackgroundImageLink: https://cdn.example.test/contra.png + """); + + var version = RemoteLauncherCatalogMapper.ToLauncherContentVersion(document); + + version.Theme.Should().BeEquivalentTo(new LauncherContentTheme + { + GenLauncherBorderColor = "#00e3ff", + GenLauncherInactiveBorder = "DarkGray", + GenLauncherInactiveBorder2 = "#7a7db0", + GenLauncherActiveColor = "#baff0c", + GenLauncherDarkFillColor = "#232977", + GenLauncherDarkBackGround = "#090502", + GenLauncherLightBackGround = "#B3000000", + GenLauncherDefaultTextColor = "White", + GenLauncherDownloadTextColor = "#090502", + GenLauncherListBoxSelectionColor1 = "#E61d2057", + GenLauncherListBoxSelectionColor2 = "#F21d2057", + GenLauncherButtonSelectionColor = "#2534ff", + GenLauncherBackgroundImageLink = "https://cdn.example.test/contra.png" + }); + } + + /// + /// A child manifest is reached through the parent that lists it, and that link is authoritative: a manifest + /// shared between parents declares only one of them, so the caller's parent has to win. + /// + [Fact] + public async Task PublishedContentYaml_PrefersCallerSuppliedParentOverDeclaredDependenceAsync() + { + LegacyContentManifest document = await ReadRemoteYamlAsync( + """ + ModificationType: Addon + Name: HD Textures + Version: '1.0' + DependenceName: ShockWave + """); + + var version = RemoteLauncherCatalogMapper.ToLauncherContentVersion(document, "Contra"); + + version.ParentContentName.Should().Be("Contra"); + } + + /// + /// An empty block carries no intent, so it must not be mistaken for a modification asking to be re-skinned. + /// + [Fact] + public async Task PublishedContentYamlWithoutUsableThemeValues_MapsToNoThemeAsync() + { + LegacyContentManifest document = await ReadRemoteYamlAsync( + """ + Name: Contra + ColorsInformation: + GenLauncherActiveColor: ' ' + """); + + RemoteLauncherCatalogMapper.ToLauncherContentVersion(document).Theme.Should().BeNull(); + } + + [Theory] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherBorderColor))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherInactiveBorder))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherInactiveBorder2))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherActiveColor))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherDarkFillColor))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherDarkBackGround))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherLightBackGround))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherDefaultTextColor))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherDownloadTextColor))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherListBoxSelectionColor1))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherListBoxSelectionColor2))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherButtonSelectionColor))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherBackgroundImageLink))] + public void PublishedContentTheme_KeepsAnySingleDeclaredSlot(string propertyName) + { + LegacyContentThemeManifest theme = CreateThemeWithSingleValue(propertyName); + var document = new LegacyContentManifest { ColorsInformation = theme }; + + var result = RemoteLauncherCatalogMapper.ToLauncherContentVersion(document); + + result.Theme.Should().NotBeNull(); + } + + [Fact] + public void ToRemoteCatalog_NullCollections_MapsEmptyCollections() + { + var document = new LegacyLauncherCatalogDocument + { + AdvData = null!, + modDatas = null!, + originalGameAddons = null!, + originalGamePatches = null! + }; + + RemoteLauncherCatalog result = RemoteLauncherCatalogMapper.ToRemoteCatalog(document); + + result.AdvertisingEntries.Should().BeEmpty(); + result.Modifications.Should().BeEmpty(); + result.OriginalGameAddonManifestUrls.Should().BeEmpty(); + result.OriginalGamePatchManifestUrls.Should().BeEmpty(); + } + + [Fact] + public async Task PublishedContentYaml_UsesDeclaredSourceKindWhenPackageMetadataIsAbsentAsync() + { + LegacyContentManifest document = await ReadRemoteYamlAsync( + """ + Name: Manually Installed + ContentSourceKind: Manual + """); + + var version = RemoteLauncherCatalogMapper.ToLauncherContentVersion(document); + + version.ModificationType.Should().Be(ModificationType.Mod); + version.Name.Should().Be("Manually Installed"); + version.Version.Should().BeEmpty(); + version.Deprecated.Should().BeFalse(); + version.Installation.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + } + + [Fact] + public void ToRemoteCatalog_ReturnsEmptyCatalogForNullManifest() + { + RemoteLauncherCatalog result = RemoteLauncherCatalogMapper.ToRemoteCatalog(null); + + result.Should().BeSameAs(RemoteLauncherCatalog.Empty); + } + + [Theory] + [InlineData(null, "")] + [InlineData("ShockWave", "ShockWave")] + public void ToLauncherContentVersion_NullManifest_MapsParentWithEmptyDefaults( + string? parentContentName, + string expectedParentContentName) + { + var result = RemoteLauncherCatalogMapper.ToLauncherContentVersion(null, parentContentName); + + result.Should().BeEquivalentTo(new LauncherContentVersion + { + ParentContentName = expectedParentContentName + }); + } + + private static async Task ReadRemoteYamlAsync(string yaml) + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(yaml, Encoding.UTF8) + }); + using HttpClient httpClient = new(handler); + HttpRemoteYamlDocumentReader reader = new(httpClient); + + return await reader.ReadYamlAsync( + new Uri("https://example.test/catalog.yaml"), + CancellationToken.None); + } + + private static LegacyContentThemeManifest CreateThemeWithSingleValue(string propertyName) + { + var theme = new LegacyContentThemeManifest(); + typeof(LegacyContentThemeManifest).GetProperty(propertyName)!.SetValue(theme, "published"); + return theme; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Persistence/Services/AtomicFileWriterTests.cs b/GenLauncherGO.Tests/Infrastructure/Persistence/Services/AtomicFileWriterTests.cs new file mode 100644 index 00000000..430e1bac --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Persistence/Services/AtomicFileWriterTests.cs @@ -0,0 +1,412 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Persistence.Services; + +namespace GenLauncherGO.Tests.Infrastructure.Persistence.Services; + +public sealed class AtomicFileWriterTests +{ + [Fact] + public void WriteText_MissingDestination_CreatesParentAndUtf8FileWithoutBom() + { + using TestDirectory directory = new(); + string documentPath = directory.GetPath("State/settings.yaml"); + string destinationDirectory = Path.GetDirectoryName(documentPath)!; + const string Contents = "Name: Δ"; + var writer = new AtomicFileWriter(); + + writer.WriteText(documentPath, Contents); + + File.ReadAllBytes(documentPath).Should().Equal(new UTF8Encoding(false).GetBytes(Contents)); + Directory.EnumerateFileSystemEntries(destinationDirectory).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + [Fact] + public void WriteText_ExistingDestination_ReplacesCompleteContents() + { + using TestDirectory directory = new(); + string documentPath = directory.CreateFile("state.yaml", "Name: original and longer"); + var writer = new AtomicFileWriter(); + + writer.WriteText(documentPath, "Name: replacement"); + + File.ReadAllText(documentPath).Should().Be("Name: replacement"); + Directory.EnumerateFileSystemEntries(directory.Path).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + [Fact] + public void WriteText_CommitFailure_PreservesOriginalAndCleansTemporaryFile() + { + using TestDirectory directory = new(); + string documentPath = directory.CreateFile("state.yaml", "Name: original"); + var writer = new AtomicFileWriter(); + using FileStream lockedDocument = new( + documentPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + + Action act = () => writer.WriteText(documentPath, "Name: replacement"); + + act.Should().Throw(); + File.ReadAllText(documentPath).Should().Be("Name: original"); + Directory.EnumerateFileSystemEntries(directory.Path).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + /// + /// The destination directory is created when it is missing, so the path chain has to be cleared before that + /// happens: creating it first would plant a launcher directory inside whatever the link resolves to, and the + /// later refusal would not take it back. + /// + [Fact] + public void WriteText_MissingDirectoryUnderLinkedParent_RejectsWithoutCreatingIt() + { + using TestDirectory directory = new(); + string linkPath = directory.GetPath("Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + string documentPath = Path.Combine(linkPath, "State", "state.yaml"); + var writer = new AtomicFileWriter(); + + Action act = () => writer.WriteText(documentPath, "Name: unsafe"); + + act.Should().Throw(); + Directory.EnumerateFileSystemEntries(junction.TargetDirectory).Should().ContainSingle() + .Which.Should().Be(junction.CanaryFilePath); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void WriteText_LinkedParent_RejectsWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string linkPath = directory.GetPath("Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + string documentPath = Path.Combine(linkPath, "state.yaml"); + var writer = new AtomicFileWriter(); + + Action act = () => writer.WriteText(documentPath, "Name: unsafe"); + + act.Should().Throw(); + File.Exists(Path.Combine(junction.TargetDirectory, "state.yaml")).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public async Task WriteAsync_MissingDestination_WritesCompleteContentsAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.GetPath("State/state.bin"); + string destinationDirectory = Path.GetDirectoryName(documentPath)!; + byte[] expectedBytes = [0, 1, 2, 3, 255]; + var writer = new AtomicFileWriter(); + + await writer.WriteAsync( + documentPath, + (stream, cancellationToken) => stream.WriteAsync(expectedBytes, cancellationToken).AsTask(), + CancellationToken.None); + + File.ReadAllBytes(documentPath).Should().Equal(expectedBytes); + Directory.EnumerateFileSystemEntries(destinationDirectory).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + /// + /// The staged bytes must land beside the destination and stay invisible to a reader until the commit, which is + /// what makes the replace atomic on the same volume. + /// + [Fact] + public async Task WriteAsync_BeforeCommit_StagesOneSiblingEntryBesideTheUnchangedDestinationAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.CreateFile("state.yaml", "Name: original"); + var writer = new AtomicFileWriter(); + List stagedEntries = []; + string observedContents = string.Empty; + + await writer.WriteAsync( + documentPath, + async (stream, cancellationToken) => + { + stagedEntries.AddRange(Directory.EnumerateFileSystemEntries(directory.Path)); + observedContents = await File.ReadAllTextAsync(documentPath, cancellationToken); + await stream.WriteAsync(Encoding.UTF8.GetBytes("Name: replacement"), cancellationToken); + }, + CancellationToken.None); + + stagedEntries.Should().HaveCount(2).And.Contain(documentPath); + observedContents.Should().Be("Name: original"); + File.ReadAllText(documentPath).Should().Be("Name: replacement"); + Directory.EnumerateFileSystemEntries(directory.Path).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + [Fact] + public async Task WriteAsync_PreCanceledToken_LeavesDestinationUntouchedAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.CreateFile("state.yaml", "Name: original"); + var writer = new AtomicFileWriter(); + bool writerCalled = false; + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + Func act = () => writer.WriteAsync( + documentPath, + (_, _) => + { + writerCalled = true; + return Task.CompletedTask; + }, + cancellationTokenSource.Token); + + await act.Should().ThrowAsync(); + writerCalled.Should().BeFalse(); + File.ReadAllText(documentPath).Should().Be("Name: original"); + Directory.EnumerateFileSystemEntries(directory.Path).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + [Fact] + public async Task WriteAsync_CanceledDuringWrite_PreservesOriginalAndCleansTemporaryFileAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.CreateFile("state.yaml", "Name: original"); + var writer = new AtomicFileWriter(); + using var cancellationTokenSource = new CancellationTokenSource(); + + Func act = () => writer.WriteAsync( + documentPath, + async (stream, cancellationToken) => + { + byte[] replacement = Encoding.UTF8.GetBytes("Name: replacement"); + await stream.WriteAsync(replacement.AsMemory(), cancellationToken); + await cancellationTokenSource.CancelAsync(); + }, + cancellationTokenSource.Token); + + await act.Should().ThrowAsync(); + File.ReadAllText(documentPath).Should().Be("Name: original"); + Directory.EnumerateFileSystemEntries(directory.Path).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + [Fact] + public async Task WriteAsync_WriterFailure_PreservesOriginalAndCleansTemporaryFileAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.CreateFile("state.yaml", "Name: original"); + var writer = new AtomicFileWriter(); + + Func act = () => writer.WriteAsync( + documentPath, + async (stream, cancellationToken) => + { + byte[] replacement = Encoding.UTF8.GetBytes("Name: replacement"); + await stream.WriteAsync(replacement.AsMemory(), cancellationToken); + throw new IOException("simulated write failure"); + }, + CancellationToken.None); + + await act.Should().ThrowAsync(); + File.ReadAllText(documentPath).Should().Be("Name: original"); + Directory.EnumerateFileSystemEntries(directory.Path).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + [Fact] + public async Task WriteAsync_LinkedDestination_RejectsWithoutTouchingTargetAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.GetPath("state.yaml"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, documentPath); + var writer = new AtomicFileWriter(); + bool writerCalled = false; + + Func act = () => writer.WriteAsync( + documentPath, + (_, _) => + { + writerCalled = true; + return Task.CompletedTask; + }, + CancellationToken.None); + + await act.Should().ThrowAsync(); + writerCalled.Should().BeFalse(); + Directory.EnumerateFileSystemEntries(junction.TargetDirectory).Should().ContainSingle() + .Which.Should().Be(junction.CanaryFilePath); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// A link planted between the safety check and the commit must still be refused, because the commit is the step + /// that would follow it and overwrite whatever the link resolves to. + /// + [Fact] + public async Task WriteAsync_DestinationLinkedDuringWrite_RejectsCommitWithoutTouchingTargetAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.GetPath("state.yaml"); + var writer = new AtomicFileWriter(); + ProtectedJunction? junction = null; + + Func act = () => writer.WriteAsync( + documentPath, + async (stream, cancellationToken) => + { + await stream.WriteAsync(Encoding.UTF8.GetBytes("Name: replacement"), cancellationToken); + junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, documentPath); + }, + CancellationToken.None); + + await act.Should().ThrowAsync(); + junction.Should().NotBeNull(); + Directory.EnumerateFileSystemEntries(junction!.TargetDirectory).Should().ContainSingle() + .Which.Should().Be(junction.CanaryFilePath); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// Another writer can create the destination between the safety check and the commit, and the commit still owns + /// the final contents. + /// + [Fact] + public async Task WriteAsync_DestinationCreatedDuringWrite_ReplacesItWithTheCommittedContentsAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.GetPath("State/state.yaml"); + string destinationDirectory = Path.GetDirectoryName(documentPath)!; + var writer = new AtomicFileWriter(); + + await writer.WriteAsync( + documentPath, + async (stream, cancellationToken) => + { + await stream.WriteAsync(Encoding.UTF8.GetBytes("committed document"), cancellationToken); + await File.WriteAllTextAsync(documentPath, "racing document", cancellationToken); + }, + CancellationToken.None); + + File.ReadAllText(documentPath).Should().Be("committed document"); + Directory.EnumerateFileSystemEntries(destinationDirectory).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + [Fact] + public async Task WriteAsync_ConcurrentCreatorsLeaveOneCompleteDocumentAndNoTemporaryFilesAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.GetPath("State/state.yaml"); + string destinationDirectory = Path.GetDirectoryName(documentPath)!; + var writer = new AtomicFileWriter(); + TaskCompletionSource firstReady = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource secondReady = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource releaseWriters = new(TaskCreationOptions.RunContinuationsAsynchronously); + + Task firstWrite = writer.WriteAsync( + documentPath, + async (stream, cancellationToken) => + { + await stream.WriteAsync(Encoding.UTF8.GetBytes("first complete document"), cancellationToken); + firstReady.TrySetResult(); + await releaseWriters.Task.WaitAsync(cancellationToken); + }, + CancellationToken.None); + Task secondWrite = writer.WriteAsync( + documentPath, + async (stream, cancellationToken) => + { + await stream.WriteAsync(Encoding.UTF8.GetBytes("second complete document"), cancellationToken); + secondReady.TrySetResult(); + await releaseWriters.Task.WaitAsync(cancellationToken); + }, + CancellationToken.None); + + try + { + await Task.WhenAll(firstReady.Task, secondReady.Task).WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + } + finally + { + releaseWriters.TrySetResult(); + } + + await Task.WhenAll(firstWrite, secondWrite).WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + File.ReadAllText(documentPath).Should().BeOneOf( + "first complete document", + "second complete document"); + Directory.EnumerateFileSystemEntries(destinationDirectory).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + [Fact] + public async Task WriteFileIfMissingAsync_MissingDestination_PublishesCompletedFileAsync() + { + using TestDirectory directory = new(); + string destinationPath = directory.GetPath("Images/asset.png"); + string destinationDirectory = Path.GetDirectoryName(destinationPath)!; + var writer = new AtomicFileWriter(); + + bool committed = await writer.WriteFileIfMissingAsync( + destinationPath, + (temporaryPath, cancellationToken) => + File.WriteAllTextAsync(temporaryPath, "complete asset", cancellationToken), + CancellationToken.None); + + committed.Should().BeTrue(); + File.ReadAllText(destinationPath).Should().Be("complete asset"); + Directory.EnumerateFileSystemEntries(destinationDirectory).Should().ContainSingle() + .Which.Should().Be(destinationPath); + } + + [Fact] + public async Task WriteFileIfMissingAsync_ExistingDestination_SkipsWriterAndKeepsContentsAsync() + { + using TestDirectory directory = new(); + string destinationPath = directory.CreateFile("asset.png", "existing asset"); + var writer = new AtomicFileWriter(); + bool writerCalled = false; + + bool committed = await writer.WriteFileIfMissingAsync( + destinationPath, + (_, _) => + { + writerCalled = true; + return Task.CompletedTask; + }, + CancellationToken.None); + + committed.Should().BeFalse(); + writerCalled.Should().BeFalse(); + File.ReadAllText(destinationPath).Should().Be("existing asset"); + } + + [Fact] + public async Task WriteFileIfMissingAsync_DestinationCreatedDuringWrite_KeepsConcurrentFileAsync() + { + using TestDirectory directory = new(); + string destinationPath = directory.GetPath("asset.png"); + var writer = new AtomicFileWriter(); + + bool committed = await writer.WriteFileIfMissingAsync( + destinationPath, + async (temporaryPath, cancellationToken) => + { + await File.WriteAllTextAsync(temporaryPath, "staged asset", cancellationToken); + await File.WriteAllTextAsync(destinationPath, "concurrent asset", cancellationToken); + }, + CancellationToken.None); + + committed.Should().BeFalse(); + File.ReadAllText(destinationPath).Should().Be("concurrent asset"); + Directory.EnumerateFileSystemEntries(directory.Path).Should().ContainSingle() + .Which.Should().Be(destinationPath); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Persistence/Services/YamlDocumentStoreTests.cs b/GenLauncherGO.Tests/Infrastructure/Persistence/Services/YamlDocumentStoreTests.cs new file mode 100644 index 00000000..0f9cc147 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Persistence/Services/YamlDocumentStoreTests.cs @@ -0,0 +1,107 @@ +using System; +using System.IO; +using GenLauncherGO.Infrastructure.Persistence.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Persistence.Services; + +public sealed class YamlDocumentStoreTests +{ + [Fact] + public void Load_WhenDocumentIsMissing_ReturnsDefaultDocument() + { + using var directory = new TestDirectory(); + var defaultDocument = new TestDocument { Name = "default" }; + IYamlDocumentStore store = CreateStore(Path.Combine(directory.Path, "state.yaml")); + + TestDocument document = store.Load(defaultDocument); + + document.Should().BeSameAs(defaultDocument); + } + + [Fact] + public void Load_WhenDocumentIsMalformed_ReturnsDefaultDocument() + { + using var directory = new TestDirectory(); + string documentPath = Path.Combine(directory.Path, "state.yaml"); + var defaultDocument = new TestDocument { Name = "default" }; + File.WriteAllText(documentPath, "Name: ["); + IYamlDocumentStore store = CreateStore(documentPath); + + TestDocument document = store.Load(defaultDocument); + + document.Should().BeSameAs(defaultDocument); + } + + /// + /// Launcher state decides what gets deployed into a user's game folder. A document reached through a link is + /// state somebody else placed there, so it is discarded in favour of the caller's default rather than trusted. + /// + [Fact] + public void Load_WhenDocumentPathCrossesALink_ReturnsDefaultDocument() + { + using var directory = new TestDirectory(); + string linkPath = directory.GetPath("Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + File.WriteAllText(Path.Combine(junction.TargetDirectory, "state.yaml"), "Name: planted"); + var defaultDocument = new TestDocument { Name = "default" }; + IYamlDocumentStore store = CreateStore(Path.Combine(linkPath, "state.yaml")); + + TestDocument document = store.Load(defaultDocument); + + document.Should().BeSameAs(defaultDocument); + } + + [Fact] + public void Save_WritesDocumentThatCanBeLoaded() + { + 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 + }; + + store.Save(document); + TestDocument loadedDocument = store.Load(new TestDocument()); + + loadedDocument.Name.Should().Be("ShockWave"); + loadedDocument.Version.Should().Be("1.2"); + loadedDocument.Installed.Should().BeTrue(); + File.Exists(documentPath).Should().BeTrue(); + } + + [Fact] + public void Save_WhenDocumentPathIsDirectory_PropagatesPersistenceFailure() + { + using var directory = new TestDirectory(); + string documentPath = Path.Combine(directory.Path, "State"); + Directory.CreateDirectory(documentPath); + IYamlDocumentStore store = CreateStore(documentPath); + + Action act = () => store.Save(new TestDocument { Name = "ShockWave" }); + + act.Should().Throw(); + Directory.Exists(documentPath).Should().BeTrue(); + } + + private static YamlDocumentStore CreateStore(string documentPath) + { + return new YamlDocumentStore( + documentPath, + new AtomicFileWriter(), + 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..982e779b --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteAssetDownloaderTests.cs @@ -0,0 +1,173 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Infrastructure.Remote; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Remote; + +public sealed class HttpRemoteAssetDownloaderTests +{ + [Fact] + public async Task DownloadIfMissingAsync_DeletesStaleTemporaryFileAndDoesNotResumeAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "asset.png"); + string temporaryFilePath = destinationFilePath + ".download"; + await File.WriteAllTextAsync(temporaryFilePath, "stale", TestContext.Current.CancellationToken); + RecordingFileDownloader fileDownloader = new() + { + Handler = (request, cancellationToken) => + File.WriteAllTextAsync(request.DestinationFilePath, "fresh", cancellationToken) + }; + HttpRemoteAssetDownloader downloader = CreateDownloader(fileDownloader); + + await downloader.DownloadIfMissingAsync( + new Uri("https://example.test/asset.png"), + destinationFilePath, + CancellationToken.None); + + fileDownloader.Requests.Should().ContainSingle() + .Which.Resume.Should().BeFalse(); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("fresh"); + File.Exists(temporaryFilePath).Should().BeFalse(); + } + + /// + /// The transfer never resumes, so leftover bytes from an interrupted session have to be gone before the next + /// one starts writing into the same staging file. + /// + [Fact] + public async Task DownloadIfMissingAsync_StaleTemporaryFile_IsGoneWhenTheDownloadStartsAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "asset.png"); + await File.WriteAllTextAsync(destinationFilePath + ".download", "stale", TestContext.Current.CancellationToken); + bool? staleFileExistedAtDownload = null; + RecordingFileDownloader fileDownloader = new() + { + Handler = (request, cancellationToken) => + { + staleFileExistedAtDownload = File.Exists(request.DestinationFilePath); + return File.WriteAllTextAsync(request.DestinationFilePath, "fresh", cancellationToken); + } + }; + HttpRemoteAssetDownloader downloader = CreateDownloader(fileDownloader); + + await downloader.DownloadIfMissingAsync( + new Uri("https://example.test/asset.png"), + destinationFilePath, + CancellationToken.None); + + staleFileExistedAtDownload.Should().BeFalse(); + } + + /// + /// Remote assets are cached below folders the launcher creates on demand, so the destination folder cannot be + /// assumed to exist when the download is requested. + /// + [Fact] + public async Task DownloadIfMissingAsync_MissingDestinationDirectory_IsCreatedBeforeDownloadingAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "Images", "Contra", "asset.png"); + RecordingFileDownloader fileDownloader = new() + { + Handler = (request, cancellationToken) => + File.WriteAllTextAsync(request.DestinationFilePath, "fresh", cancellationToken) + }; + HttpRemoteAssetDownloader downloader = CreateDownloader(fileDownloader); + + await downloader.DownloadIfMissingAsync( + new Uri("https://example.test/asset.png"), + destinationFilePath, + CancellationToken.None); + + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("fresh"); + } + + /// + /// Closing the launcher cancels asset downloads mid-transfer. The partially staged file must never be published + /// as the finished asset, because a present destination is what stops the next session downloading it again. + /// + [Fact] + public async Task DownloadIfMissingAsync_CanceledDuringDownload_LeavesTheDestinationMissingAsync() + { + using TestDirectory testDirectory = new(); + using CancellationTokenSource cancellation = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "asset.png"); + RecordingFileDownloader fileDownloader = new() + { + Handler = async (request, cancellationToken) => + { + await File.WriteAllTextAsync(request.DestinationFilePath, "partial", cancellationToken); + await cancellation.CancelAsync(); + } + }; + HttpRemoteAssetDownloader downloader = CreateDownloader(fileDownloader); + + Func act = () => downloader.DownloadIfMissingAsync( + new Uri("https://example.test/asset.png"), + destinationFilePath, + cancellation.Token); + + await act.Should().ThrowAsync(); + File.Exists(destinationFilePath).Should().BeFalse(); + } + + [Fact] + public async Task DownloadIfMissingAsync_ExistingDestination_KeepsContentsWithoutDownloadingAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = testDirectory.CreateFile("asset.png", "already downloaded"); + RecordingFileDownloader fileDownloader = new(); + HttpRemoteAssetDownloader downloader = CreateDownloader(fileDownloader); + + await downloader.DownloadIfMissingAsync( + new Uri("https://example.test/asset.png"), + destinationFilePath, + CancellationToken.None); + + fileDownloader.Requests.Should().BeEmpty(); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("already downloaded"); + } + + /// + /// Another session can finish the same asset while this download runs, so the completed destination must win + /// over the temporary file this call staged. + /// + [Fact] + public async Task DownloadIfMissingAsync_DestinationAppearsDuringDownload_KeepsItAndRemovesTemporaryFileAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "asset.png"); + string temporaryFilePath = destinationFilePath + ".download"; + RecordingFileDownloader fileDownloader = new() + { + Handler = async (request, cancellationToken) => + { + await File.WriteAllTextAsync(request.DestinationFilePath, "this download", cancellationToken); + await File.WriteAllTextAsync(destinationFilePath, "the other session", cancellationToken); + } + }; + HttpRemoteAssetDownloader downloader = CreateDownloader(fileDownloader); + + await downloader.DownloadIfMissingAsync( + new Uri("https://example.test/asset.png"), + destinationFilePath, + CancellationToken.None); + + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("the other session"); + File.Exists(temporaryFilePath).Should().BeFalse(); + } + + private static HttpRemoteAssetDownloader CreateDownloader(RecordingFileDownloader fileDownloader) + { + return new HttpRemoteAssetDownloader( + fileDownloader, + new AtomicFileWriter(), + NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteConnectionProbeTests.cs b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteConnectionProbeTests.cs new file mode 100644 index 00000000..4bb1d13c --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteConnectionProbeTests.cs @@ -0,0 +1,115 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Remote; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Remote; + +public sealed class HttpRemoteConnectionProbeTests +{ + [Fact] + public async Task CanConnectAsync_ReturnsTrueWhenHeadSucceedsAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.NoContent)); + HttpRemoteConnectionProbe probe = CreateProbe(handler); + + bool canConnect = await probe.CanConnectAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + canConnect.Should().BeTrue(); + handler.Methods.Should().Equal(HttpMethod.Head); + } + + [Theory] + [InlineData(HttpStatusCode.MethodNotAllowed)] + [InlineData(HttpStatusCode.NotImplemented)] + public async Task CanConnectAsync_FallsBackToGetWhenHeadIsNotAllowedAsync(HttpStatusCode headStatusCode) + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(headStatusCode)); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK)); + HttpRemoteConnectionProbe probe = CreateProbe(handler); + + bool canConnect = await probe.CanConnectAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + canConnect.Should().BeTrue(); + handler.Methods.Should().Equal(HttpMethod.Head, HttpMethod.Get); + } + + [Fact] + public async Task CanConnectAsync_FallsBackToGetWhenHeadReturnsAnotherFailureAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK)); + HttpRemoteConnectionProbe probe = CreateProbe(handler); + + bool canConnect = await probe.CanConnectAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + canConnect.Should().BeTrue(); + handler.Methods.Should().Equal(HttpMethod.Head, HttpMethod.Get); + } + + [Fact] + public async Task CanConnectAsync_ReturnsFalseWhenRequestsFailAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => throw new HttpRequestException("network down")); + HttpRemoteConnectionProbe probe = CreateProbe(handler); + + bool canConnect = await probe.CanConnectAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + canConnect.Should().BeFalse(); + } + + [Fact] + public async Task CanConnectAsync_ReturnsFalseWhenProbeTimesOutAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => throw new TaskCanceledException("timeout")); + HttpRemoteConnectionProbe probe = CreateProbe(handler); + + bool canConnect = await probe.CanConnectAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + canConnect.Should().BeFalse(); + } + + /// + /// A caller-requested cancellation must reach the caller instead of being reported as an unreachable endpoint. + /// + [Fact] + public async Task CanConnectAsync_CanceledToken_PropagatesCancellationAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => throw new TaskCanceledException("canceled")); + HttpRemoteConnectionProbe probe = CreateProbe(handler); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + Func act = () => probe.CanConnectAsync( + new Uri("https://example.test/catalog.yml"), + cancellation.Token); + + await act.Should().ThrowAsync(); + } + + private static HttpRemoteConnectionProbe CreateProbe(QueueHttpMessageHandler handler) + { + return new HttpRemoteConnectionProbe( + NullLogger.Instance, + new HttpClient(handler)); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteYamlDocumentReaderTests.cs b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteYamlDocumentReaderTests.cs new file mode 100644 index 00000000..988d2e22 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteYamlDocumentReaderTests.cs @@ -0,0 +1,93 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Remote; + +namespace GenLauncherGO.Tests.Infrastructure.Remote; + +public sealed class HttpRemoteYamlDocumentReaderTests +{ + /// + /// The backend adds keys the launcher has never seen without a schema change, so the fixture carries a scalar + /// and a nested mapping the document does not declare. + /// + [Fact] + public async Task ReadYamlAsync_DeserializesRemoteYamlAsync() + { + const string RemoteYaml = """ + Name: ShockWave + Version: '1.2' + FutureField: whatever + FutureSection: + Nested: value + Deeper: + Key: 1 + + """; + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(RemoteYaml, Encoding.UTF8) + }); + HttpRemoteYamlDocumentReader reader = new(new HttpClient(handler)); + + RemoteDocument document = await reader.ReadYamlAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + document.Name.Should().Be("ShockWave"); + document.Version.Should().Be("1.2"); + handler.Requests.Should().ContainSingle() + .Which.Method.Should().Be(HttpMethod.Get); + } + + [Fact] + public async Task ReadYamlAsync_ThrowsForUnsuccessfulResponseAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); + HttpRemoteYamlDocumentReader reader = new(new HttpClient(handler)); + + Func act = () => reader.ReadYamlAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + /// + /// A caller-requested cancellation must reach the caller unchanged instead of being reported as a failed read. + /// + [Fact] + public async Task ReadYamlAsync_CanceledToken_PropagatesCancellationAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => throw new TaskCanceledException("canceled")); + HttpRemoteYamlDocumentReader reader = new(new HttpClient(handler)); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + Func act = () => reader.ReadYamlAsync( + new Uri("https://example.test/catalog.yml"), + cancellation.Token); + + await act.Should().ThrowAsync(); + } + + /// + /// A stand-in for a remote YAML document. + /// + /// + /// The setters exist for the deserializer, not for this file, so they look unused to a "make it read-only" + /// inspection. Removing them makes deserialization silently yield empty values instead of failing to build. + /// + private sealed class RemoteDocument + { + public string Name { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Remote/SharedHttpClientFactoryTests.cs b/GenLauncherGO.Tests/Infrastructure/Remote/SharedHttpClientFactoryTests.cs new file mode 100644 index 00000000..4060e458 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Remote/SharedHttpClientFactoryTests.cs @@ -0,0 +1,136 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Remote; + +namespace GenLauncherGO.Tests.Infrastructure.Remote; + +/// +/// Pins the transport contract shared by every launcher HTTP client. +/// +/// +/// Automatic decompression has to stay off. The resumable downloader resumes at a byte offset and measures its +/// progress against the length the server declared, so a handler that silently inflated the body would write +/// more bytes than that length and leave every resumed transfer corrupt. The server here is a loopback socket +/// so the invariant is observed the way a real server would expose it, without reaching the network. +/// +public sealed class SharedHttpClientFactoryTests +{ + [Fact] + public async Task Create_LeavesContentEncodingUnnegotiatedAndUnappliedAsync() + { + byte[] compressedBody = Compress("payload"); + (int Port, Task Request) server = StartLoopbackServer(compressedBody); + byte[] received; + + using (HttpClient httpClient = SharedHttpClientFactory.Create(TestTimeouts.Wait)) + { + received = await httpClient.GetByteArrayAsync(new Uri($"http://127.0.0.1:{server.Port}/asset"), TestContext.Current.CancellationToken); + } + + string request = await server.Request; + received.Should().Equal( + compressedBody, + "a decompressing handler would hand back the inflated body instead of the bytes on the wire"); + request.Should().NotContain( + "Accept-Encoding", + "advertising an encoding invites the compressed response that breaks resume arithmetic"); + request.Should().Contain("User-Agent: GenLauncherGO/1"); + } + + [Fact] + public void Create_AppliesTheRequestedTimeout() + { + using HttpClient httpClient = SharedHttpClientFactory.Create(TimeSpan.FromSeconds(37)); + + httpClient.Timeout.Should().Be(TimeSpan.FromSeconds(37)); + } + + /// + /// Starts a loopback server that serves one gzip-encoded response. The returned task owns the listener and + /// closes it, so the caller never holds a socket it might dispose while the exchange is still running. + /// + private static (int Port, Task Request) StartLoopbackServer(byte[] body) + { + TcpListener listener = new(IPAddress.Loopback, 0); + listener.Start(); + return (((IPEndPoint)listener.LocalEndpoint).Port, RespondOnceAsync(listener, body)); + } + + /// + /// Serves one gzip-encoded response and returns the raw request text the client sent. + /// + private static async Task RespondOnceAsync(TcpListener listener, byte[] body) + { + try + { + return await ExchangeAsync(listener, body); + } + finally + { + listener.Stop(); + } + } + + private static async Task ExchangeAsync(TcpListener listener, byte[] body) + { + using TcpClient client = await listener.AcceptTcpClientAsync(); + await using NetworkStream stream = client.GetStream(); + string request = await ReadRequestHeadersAsync(stream); + string responseHeaders = + "HTTP/1.1 200 OK\r\n" + + "Content-Encoding: gzip\r\n" + + "Content-Type: application/octet-stream\r\n" + + $"Content-Length: {body.Length}\r\n" + + "Connection: close\r\n\r\n"; + + await stream.WriteAsync(Encoding.ASCII.GetBytes(responseHeaders)); + await stream.WriteAsync(body); + await stream.FlushAsync(); + return request; + } + + private static async Task ReadRequestHeadersAsync(NetworkStream stream) + { + StringBuilder text = new(); + byte[] buffer = new byte[1]; + while (!EndsWithBlankLine(text)) + { + int read = await stream.ReadAsync(buffer); + if (read == 0) + { + break; + } + + text.Append((char)buffer[0]); + } + + return text.ToString(); + } + + private static bool EndsWithBlankLine(StringBuilder text) + { + return text.Length >= 4 && + text[^4] == '\r' && + text[^3] == '\n' && + text[^2] == '\r' && + text[^1] == '\n'; + } + + private static byte[] Compress(string payload) + { + using MemoryStream output = new(); + using (GZipStream gzip = new(output, CompressionLevel.Optimal, true)) + { + byte[] bytes = Encoding.UTF8.GetBytes(payload); + gzip.Write(bytes, 0, bytes.Length); + } + + return output.ToArray(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.cs new file mode 100644 index 00000000..0be3f229 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.cs @@ -0,0 +1,632 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Settings.Exceptions; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Infrastructure.Settings.Models; +using GenLauncherGO.Infrastructure.Settings.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Settings.Services; + +public sealed class PreferencesServiceTests +{ + [Fact] + public void Current_WhenPreferencesFileIsMissing_ReturnsCurrentSchemaDefaults() + { + using var directory = new TestDirectory(); + + PreferencesService service = CreateService( + Path.Combine(directory.Path, "LauncherPreferences.yaml")); + + service.Current.Should().Be(new LauncherPreferences()); + } + + [Fact] + public void Current_WhenPreferencesFileIsMalformed_ReturnsDefaults() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + "Installations: ["); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Should().Be(new LauncherPreferences()); + string resetYaml = File.ReadAllText(preferencesFilePath); + resetYaml.Should().Contain("SchemaVersion: 1"); + resetYaml.Should().NotContain("Installations: ["); + } + + [Fact] + public void Current_MigratesUnversionedFlatPreferencesToCurrentSchema() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + LaunchesCount: 7 + AutoDeleteOldVersions: true + SelectedGameClient: generalszh.exe + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Shared.AutoDeleteOldVersions.Should().BeTrue(); + service.Current.Games.ZeroHour.LaunchesCount.Should().Be(7); + service.Current.Games.ZeroHour.SelectedGameClient.Should().Be("generalszh.exe"); + + string migratedYaml = File.ReadAllText(preferencesFilePath); + migratedYaml.Should().Contain("SchemaVersion: 1"); + migratedYaml.Should().Contain("Shared:"); + migratedYaml.Should().Contain("Games:"); + File.ReadAllLines(preferencesFilePath).Should().NotContain("LaunchesCount: 7"); + File.ReadAllLines(preferencesFilePath).Should().NotContain("AutoDeleteOldVersions: true"); + } + + [Fact] + public void Current_MigratesSchemaZeroFlatPreferencesToCurrentSchema() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 0 + LaunchesCount: 4 + AutoDeleteOldVersions: true + SelectedGameClient: generalszh.exe + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Shared.AutoDeleteOldVersions.Should().BeTrue(); + service.Current.Games.ZeroHour.LaunchesCount.Should().Be(4); + service.Current.Games.ZeroHour.SelectedGameClient.Should().Be("generalszh.exe"); + string migratedYaml = File.ReadAllText(preferencesFilePath); + migratedYaml.Should().Contain("SchemaVersion: 1"); + migratedYaml.Should().NotContain("SchemaVersion: 0"); + File.ReadAllLines(preferencesFilePath).Should().NotContain("LaunchesCount: 4"); + } + + [Fact] + public void Current_WhenSchemaIsNewerThanSupported_ResetsToCurrentSchemaDefaults() + { + using var directory = new TestDirectory(); + const string FuturePreferences = + """ + SchemaVersion: 2 + Shared: + AutoDeleteOldVersions: true + FutureSetting: keep-me + """; + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + FuturePreferences); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Should().Be(new LauncherPreferences()); + string resetYaml = File.ReadAllText(preferencesFilePath); + resetYaml.Should().Contain("SchemaVersion: 1"); + resetYaml.Should().NotContain("FutureSetting"); + } + + [Fact] + public void Current_WhenFutureSchemaHasIncompatibleCurrentFieldShape_ResetsAndRewritesDefaults() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 2 + Shared: + - incompatible-future-shape + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Should().Be(new LauncherPreferences()); + string resetYaml = File.ReadAllText(preferencesFilePath); + resetYaml.Should().Contain("SchemaVersion: 1"); + resetYaml.Should().NotContain("SchemaVersion: 2"); + resetYaml.Should().NotContain("incompatible-future-shape"); + } + + [Fact] + public void Current_WhenCurrentSchemaHasIncompatibleFieldShape_ResetsAndRewritesDefaults() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 1 + Shared: + - incompatible-current-shape + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Should().Be(new LauncherPreferences()); + string resetYaml = File.ReadAllText(preferencesFilePath); + resetYaml.Should().Contain("SchemaVersion: 1"); + resetYaml.Should().NotContain("incompatible-current-shape"); + } + + [Fact] + public void Current_WhenUnversionedSchemaIsUnknown_ResetsToCurrentSchemaDefaults() + { + using var directory = new TestDirectory(); + const string UnknownPreferences = "FutureSetting: keep-me"; + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + UnknownPreferences); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Should().Be(new LauncherPreferences()); + string resetYaml = File.ReadAllText(preferencesFilePath); + resetYaml.Should().Contain("SchemaVersion: 1"); + resetYaml.Should().NotContain("FutureSetting"); + } + + [Fact] + public void Current_NormalizesNullableCurrentSchemaMembers() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 1 + Installations: + Generals: + ZeroHour: + LastSelectedGame: Unknown + Shared: + AutoDeleteOldVersions: true + Games: + Generals: + LaunchesCount: -2 + SelectedGameClient: + ZeroHour: + GameArguments: + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Installations.Should().Be(new LauncherInstallations()); + service.Current.LastSelectedGame.Should().BeNull(); + service.Current.Shared.AutoDeleteOldVersions.Should().BeTrue(); + service.Current.Games.Generals.LaunchesCount.Should().Be(0); + service.Current.Games.Generals.SelectedGameClient.Should().BeEmpty(); + service.Current.Games.Generals.SelectedWorldBuilder.Should().BeEmpty(); + service.Current.Games.Generals.WorldBuilderArguments.Should().BeEmpty(); + service.Current.Games.ZeroHour.GameArguments.Should().BeEmpty(); + } + + /// + /// Only the game a user has actually configured reaches the file, so a document that carries one game section + /// must still load the other game at its defaults instead of failing the whole read. + /// + [Fact] + public void Current_WhenOnlyOneGameSectionIsPersisted_LoadsTheOtherGameAsDefaults() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 1 + Games: + ZeroHour: + LaunchesCount: 5 + SelectedGameClient: generalszh.exe + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Games.ZeroHour.LaunchesCount.Should().Be(5); + service.Current.Games.Generals.Should().Be(new LauncherGamePreferences()); + } + + /// + /// A hand-edited or partially written preferences file can hold custom entries without both names. They are + /// dropped at the settings boundary so the launcher still starts with the entries that remain usable. + /// + [Fact] + public void Current_DiscardsPersistedCustomExecutablesMissingAName() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 1 + Games: + ZeroHour: + CustomGameClients: + - + - ExecutableName: nameless.exe + - DisplayName: No File + - DisplayName: Usable + ExecutableName: usable.exe + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Games.ZeroHour.CustomGameClients.Should().ContainSingle() + .Which.Should().Be(new LauncherCustomExecutable("Usable", "usable.exe")); + } + + /// + /// GeneralsOnline is a built-in Zero Hour client, so a persisted custom entry that reuses its file name is a + /// duplicate there while being a genuine custom client for Generals. + /// + [Fact] + public void Current_TreatsGeneralsOnlineExecutableAsBuiltInOnlyForZeroHour() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 1 + Games: + ZeroHour: + CustomGameClients: + - DisplayName: Online + ExecutableName: generalsonlinezh.exe + Generals: + CustomGameClients: + - DisplayName: Online + ExecutableName: generalsonlinezh.exe + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Games.ZeroHour.CustomGameClients.Should().BeEmpty(); + service.Current.Games.Generals.CustomGameClients.Should().ContainSingle() + .Which.ExecutableName.Should().Be(LauncherFileSystemLayout.GeneralsOnlineExecutableFileName); + } + + /// + /// Custom World Builder entries are offered alongside the built-in ones, so an entry that reuses a built-in + /// file name would otherwise appear twice in the picker. + /// + [Fact] + public void Current_DiscardsCustomWorldBuildersThatReuseABuiltInFileName() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 1 + Games: + ZeroHour: + CustomWorldBuilders: + - DisplayName: Community + ExecutableName: worldbuilderzh.exe + - DisplayName: Retail + ExecutableName: WorldBuilder.exe + - DisplayName: Custom + ExecutableName: custom-editor.exe + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Games.ZeroHour.CustomWorldBuilders.Should().ContainSingle() + .Which.ExecutableName.Should().Be("custom-editor.exe"); + } + + /// + /// A document whose schema marker cannot be read is unreadable, not legacy. Migrating its flat keys would + /// import values from a file the launcher never managed to understand. + /// + [Fact] + public void Current_WhenSchemaVersionCannotBeRead_ResetsInsteadOfMigratingFlatValues() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: not-a-version + LaunchesCount: 9 + AutoDeleteOldVersions: true + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Should().Be(new LauncherPreferences()); + } + + /// + /// The unversioned format only wrote the keys a user had changed, so absent keys have to migrate to the current + /// defaults rather than to unset values. + /// + [Fact] + public void Current_MigratesUnversionedPreferencesThatOmitOptionalKeys() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + "LaunchesCount: 3"); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Games.ZeroHour.LaunchesCount.Should().Be(3); + service.Current.Games.ZeroHour.SelectedGameClient.Should().BeEmpty(); + service.Current.Shared.AutoDeleteOldVersions.Should().BeFalse(); + } + + /// + /// A preferences file reached through a reparse point is never rewritten, so the reset the launcher would + /// otherwise persist has to fail instead of writing through the link. + /// + [Fact] + public void Current_WhenPreferencesAreReachedThroughAReparsePoint_FailsWithoutRewritingTheFile() + { + using var directory = new TestDirectory(); + const string MalformedPreferences = "Installations: ["; + string linkedDirectory = directory.GetPath("LinkedSettings"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + linkedDirectory, + "RealSettings"); + string preferencesFilePath = Path.Combine(linkedDirectory, "LauncherPreferences.yaml"); + File.WriteAllText(preferencesFilePath, MalformedPreferences); + + Action act = () => CreateService(preferencesFilePath); + + act.Should().Throw(); + File.ReadAllText(preferencesFilePath).Should().Be(MalformedPreferences); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void Update_PersistsAndReloadsStandaloneSchema() + { + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + string generalsDirectory = directory.CreateDirectory("Generals"); + string zeroHourDirectory = directory.CreateDirectory("ZeroHour"); + PreferencesService service = CreateService(preferencesFilePath); + var preferences = new LauncherPreferences + { + Installations = new LauncherInstallations + { + Generals = generalsDirectory + Path.DirectorySeparatorChar, + ZeroHour = zeroHourDirectory + }, + LastSelectedGame = SupportedGame.ZeroHour, + Shared = new LauncherSharedPreferences + { + AutoDeleteOldVersions = true, + HideLauncherAfterGameStart = true, + EnableDiagnosticLogging = true, + UseEnglishLanguage = true, + HasShownRetailGenPatcherRecommendation = true + }, + Games = new LauncherGamePreferencesSet + { + Generals = new LauncherGamePreferences + { + LaunchesCount = 3, + SelectedGameClient = " generalsv.exe ", + CustomGameClients = new[] + { + new LauncherCustomExecutable("Generals Client A", "generals-custom-a.exe"), + new LauncherCustomExecutable("Generals Client B", "generals-custom-b.exe") + } + }, + ZeroHour = new LauncherGamePreferences + { + LaunchesCount = 7, + SelectedGameClient = "generalszh.exe", + SelectedWorldBuilder = "worldbuilderzh.exe", + GameArguments = "-quickstart", + WorldBuilderArguments = "-wb", + ModsListVerticalOffset = 123.5, + AdvertisingPositionInList = 2, + CustomGameClients = new[] + { + new LauncherCustomExecutable("Zero Hour Client", "zh-custom.exe") + }, + CustomWorldBuilders = new[] + { + new LauncherCustomExecutable("Map Editor", "map-editor.exe") + } + } + } + }; + + service.Update(preferences); + PreferencesService reloadedService = CreateService(preferencesFilePath); + + LauncherPreferences persisted = reloadedService.Current; + persisted.Installations.Generals.Should().Be(Path.GetFullPath(generalsDirectory)); + persisted.Installations.ZeroHour.Should().Be(Path.GetFullPath(zeroHourDirectory)); + persisted.LastSelectedGame.Should().Be(SupportedGame.ZeroHour); + persisted.Shared.Should().Be(preferences.Shared); + persisted.Games.Generals.SelectedGameClient.Should().Be("generalsv.exe"); + persisted.Games.Generals.CustomGameClients.Should().Equal( + preferences.Games.Generals.CustomGameClients); + persisted.Games.ZeroHour.Should().BeEquivalentTo(preferences.Games.ZeroHour); + persisted.Games.ZeroHour.CustomGameClients.Should().ContainSingle() + .Which.ExecutableName.Should().Be("zh-custom.exe"); + persisted.Games.ZeroHour.CustomWorldBuilders.Should().ContainSingle() + .Which.ExecutableName.Should().Be("map-editor.exe"); + + string yaml = File.ReadAllText(preferencesFilePath); + yaml.Should().Contain("SchemaVersion: 1"); + yaml.Should().Contain("Installations:"); + yaml.Should().Contain("LastSelectedGame: ZeroHour"); + yaml.Should().Contain("Shared:"); + yaml.Should().Contain("EnableDiagnosticLogging: true"); + yaml.Should().Contain("HasShownRetailGenPatcherRecommendation: true"); + yaml.Should().Contain("Games:"); + yaml.Should().Contain("ModsListVerticalOffset: 123.5"); + yaml.Should().Contain("CustomGameClients:"); + yaml.Should().Contain("CustomWorldBuilders:"); + } + + [Fact] + public void Current_NormalizesCustomExecutablesPerGameAndRejectsInvalidOrDuplicateEntries() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 1 + Games: + ZeroHour: + CustomGameClients: + - DisplayName: First + ExecutableName: custom-one.exe + - DisplayName: first + ExecutableName: custom-two.exe + - DisplayName: Second + ExecutableName: CUSTOM-ONE.EXE + - DisplayName: Built in + ExecutableName: generalszh.exe + - DisplayName: Retail + ExecutableName: generals.exe + - DisplayName: Nested + ExecutableName: tools/custom.exe + CustomWorldBuilders: + - DisplayName: Editor + ExecutableName: editor.exe + Generals: + CustomGameClients: + - DisplayName: Generals Custom + ExecutableName: custom-one.exe + - DisplayName: Generals Retail + ExecutableName: generals.exe + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Shared.HasShownRetailGenPatcherRecommendation.Should().BeFalse(); + service.Current.Games.ZeroHour.CustomGameClients.Should().ContainSingle() + .Which.Should().Be(new LauncherCustomExecutable("First", "custom-one.exe")); + service.Current.Games.ZeroHour.CustomWorldBuilders.Should().ContainSingle() + .Which.Should().Be(new LauncherCustomExecutable("Editor", "editor.exe")); + service.Current.Games.Generals.CustomGameClients.Should().ContainSingle() + .Which.Should().Be(new LauncherCustomExecutable("Generals Custom", "custom-one.exe")); + } + + /// + /// The stored scroll offset is reapplied to the mods list on the next start, so a non-finite value has to + /// collapse to the top of the list rather than being kept and restored. + /// + [Theory] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void Update_NonFiniteModsListVerticalOffset_ResetsTheOffsetToZero(double offset) + { + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + PreferencesService service = CreateService(preferencesFilePath); + + service.Update(new LauncherPreferences + { + Games = new LauncherGamePreferencesSet + { + ZeroHour = new LauncherGamePreferences { ModsListVerticalOffset = offset } + } + }); + + service.Current.Games.ZeroHour.ModsListVerticalOffset.Should().Be(0); + } + + /// + /// An installation path that the Windows path APIs reject is dropped rather than propagated, so the launcher + /// can still start and ask for a usable directory. + /// + [Fact] + public void Update_UnusableInstallationPath_IsDiscarded() + { + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + PreferencesService service = CreateService(preferencesFilePath); + + service.Update(new LauncherPreferences + { + Installations = new LauncherInstallations { Generals = "C:\\Games\0Generals" } + }); + + service.Current.Installations.Generals.Should().BeNull(); + } + + [Fact] + public void Update_WhenPreferencesAreUnchanged_DoesNotPersistOrRaisePreferencesChanged() + { + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + PreferencesService service = CreateService(preferencesFilePath); + int changedCount = 0; + service.PreferencesChanged += (_, _) => changedCount++; + + service.Update(new LauncherPreferences()); + + changedCount.Should().Be(0); + File.Exists(preferencesFilePath).Should().BeFalse(); + } + + [Fact] + public void Update_WhenPreferencesChange_RaisesPreferencesChangedWithNormalizedState() + { + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + PreferencesService service = CreateService(preferencesFilePath); + LauncherPreferences? changedPreferences = null; + service.PreferencesChanged += (_, current) => changedPreferences = current; + var preferences = new LauncherPreferences + { + Games = new LauncherGamePreferencesSet + { + ZeroHour = new LauncherGamePreferences { GameArguments = "-quickstart" } + } + }; + + service.Update(preferences); + + changedPreferences.Should().Be(service.Current); + changedPreferences!.Games.ZeroHour.GameArguments.Should().Be("-quickstart"); + } + + [Fact] + public void Update_WhenPreferencesCannotBePersisted_KeepsCurrentAndDoesNotPublish() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateDirectory("LauncherPreferences.yaml"); + PreferencesService service = CreateService(preferencesFilePath); + LauncherPreferences? changedPreferences = null; + service.PreferencesChanged += (_, current) => changedPreferences = current; + var preferences = new LauncherPreferences + { + Shared = new LauncherSharedPreferences { AutoDeleteOldVersions = true } + }; + + Action act = () => service.Update(preferences); + + act.Should().Throw() + .WithInnerException(); + service.Current.Should().Be(new LauncherPreferences()); + changedPreferences.Should().BeNull(); + Directory.Exists(preferencesFilePath).Should().BeTrue(); + } + + private static PreferencesService CreateService(string preferencesFilePath) + { + return new PreferencesService( + new YamlDocumentStore( + preferencesFilePath, + new AtomicFileWriter(), + NullLogger>.Instance), + new YamlDocumentStore( + preferencesFilePath, + new AtomicFileWriter(), + NullLogger>.Instance), + new YamlDocumentStore( + preferencesFilePath, + new AtomicFileWriter(), + NullLogger>.Instance)); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Shell/Services/WindowsLauncherShellServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Shell/Services/WindowsLauncherShellServiceTests.cs new file mode 100644 index 00000000..68d8a40c --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Shell/Services/WindowsLauncherShellServiceTests.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using GenLauncherGO.Infrastructure.Shell.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Shell.Services; + +public sealed class WindowsLauncherShellServiceTests +{ + private readonly List _openedTargets = []; + + [Fact] + public void OpenUri_DoesNotLaunchEmptyUri() + { + WindowsLauncherShellService service = CreateService(); + + service.OpenUri(" "); + + _openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenUri_DoesNotLaunchRelativeUri() + { + WindowsLauncherShellService service = CreateService(); + + service.OpenUri("not-a-uri"); + + _openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenUri_DoesNotLaunchUnsupportedScheme() + { + WindowsLauncherShellService service = CreateService(); + + service.OpenUri("ftp://example.test/file.big"); + + _openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenUri_OpensNormalizedHttpTarget() + { + WindowsLauncherShellService service = CreateService(); + + service.OpenUri("HTTPS://Example.Test/mods?id=1"); + + _openedTargets.Should().Equal("https://example.test/mods?id=1"); + } + + /// + /// A shell that refuses the target must not take the launcher down with it. The refusal is swallowed, so the + /// report to the caller-supplied logger is the only trace left for diagnosing "the link does nothing". + /// + [Fact] + public void OpenUri_WhenTheShellRefusesTheTarget_ReportsTheFailureWithoutThrowing() + { + RecordingLogger logger = new(); + WindowsLauncherShellService service = new(logger, _ => throw new Win32Exception(5)); + + Action act = () => service.OpenUri("https://example.test/mods"); + + act.Should().NotThrow(); + logger.Entries.Should().Contain(entry => + entry.LogLevel == LogLevel.Warning && + entry.Exception is Win32Exception); + } + + [Fact] + public void OpenFolder_DoesNotLaunchEmptyFolder() + { + WindowsLauncherShellService service = CreateService(); + + service.OpenFolder(" "); + + _openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenFolder_DoesNotLaunchInvalidPath() + { + WindowsLauncherShellService service = CreateService(); + + service.OpenFolder("bad\0path"); + + _openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenFolder_DoesNotLaunchMissingFolder() + { + using TestDirectory directory = new(); + WindowsLauncherShellService service = CreateService(); + string missingFolder = Path.Combine(directory.Path, "missing"); + + service.OpenFolder(missingFolder); + + _openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenFolder_CreatesMissingFolderWhenRequested() + { + using TestDirectory directory = new(); + string missingFolder = Path.Combine(directory.Path, "Logs"); + WindowsLauncherShellService service = CreateService(); + + service.OpenFolder(missingFolder, createIfMissing: true); + + Directory.Exists(missingFolder).Should().BeTrue(); + _openedTargets.Should().Equal(Path.GetFullPath(missingFolder)); + } + + [Fact] + public void OpenFolder_DoesNotLaunchWhenMissingFolderCannotBeCreated() + { + using TestDirectory directory = new(); + string filePath = Path.Combine(directory.Path, "Logs"); + File.WriteAllText(filePath, "not a directory"); + WindowsLauncherShellService service = CreateService(); + + service.OpenFolder(filePath, createIfMissing: true); + + _openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenFolder_DoesNotLaunchEmptyFolderWhenFilesAreRequired() + { + using TestDirectory directory = new(); + WindowsLauncherShellService service = CreateService(); + + service.OpenFolder(directory.Path, true); + + _openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenFolder_OpensExistingFolder() + { + using TestDirectory directory = new(); + WindowsLauncherShellService service = CreateService(); + + service.OpenFolder(directory.Path); + + _openedTargets.Should().Equal(Path.GetFullPath(directory.Path)); + } + + [Fact] + public void OpenFolder_OpensExistingFolderWhenRequiredFilesExist() + { + using TestDirectory directory = new(); + File.WriteAllText(Path.Combine(directory.Path, "file.txt"), "content"); + WindowsLauncherShellService service = CreateService(); + + service.OpenFolder(directory.Path, true); + + _openedTargets.Should().Equal(Path.GetFullPath(directory.Path)); + } + + private WindowsLauncherShellService CreateService() + { + return CreateService(_openedTargets.Add); + } + + private static WindowsLauncherShellService CreateService(Action openShellTarget) + { + return new WindowsLauncherShellService( + NullLogger.Instance, + openShellTarget); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherPathResolverTests.cs b/GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherPathResolverTests.cs new file mode 100644 index 00000000..a7303bdb --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherPathResolverTests.cs @@ -0,0 +1,120 @@ +using System.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Startup; + +namespace GenLauncherGO.Tests.Infrastructure.Startup; + +public sealed class FileSystemLauncherPathResolverTests +{ + [Fact] + public void Resolve_UsesExecutableDirectoryWithoutInferringAGame() + { + using var directory = new TestDirectory(); + var resolver = new FileSystemLauncherPathResolver(); + + LauncherStoragePaths paths = resolver.Resolve(directory.Path); + + paths.ExecutableDirectory.Should().Be(Path.GetFullPath(directory.Path)); + } + + [Fact] + public void PrepareLauncherDirectories_CreatesOnlySharedStorage() + { + using var directory = new TestDirectory(); + var resolver = new FileSystemLauncherPathResolver(); + LauncherStoragePaths paths = resolver.Resolve(directory.Path); + string generalsDataDirectory = paths.CreateGamePaths(SupportedGame.Generals, directory.Path) + .OwnedGameDataDirectory; + string zeroHourDataDirectory = paths.CreateGamePaths(SupportedGame.ZeroHour, directory.Path) + .OwnedGameDataDirectory; + + resolver.PrepareLauncherDirectories(paths); + + Directory.Exists(paths.DataDirectory).Should().BeTrue(); + Directory.Exists(paths.LogsDirectory).Should().BeTrue(); + Directory.Exists(generalsDataDirectory).Should().BeFalse(); + Directory.Exists(zeroHourDataDirectory).Should().BeFalse(); + } + + [Fact] + public void PrepareGameDirectories_CreatesIsolatedLayoutAndClearsOnlyTemp() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = directory.CreateDirectory("Game"); + var resolver = new FileSystemLauncherPathResolver(); + LauncherStoragePaths storage = resolver.Resolve(executableDirectory); + resolver.PrepareLauncherDirectories(storage); + LauncherPaths paths = storage.CreateGamePaths(SupportedGame.ZeroHour, gameDirectory); + string staleTempFile = Path.Combine(paths.TempDirectory, "download.part"); + string deploymentJournal = Path.Combine(paths.DeploymentDirectory, "journal.json"); + Directory.CreateDirectory(paths.TempDirectory); + Directory.CreateDirectory(paths.DeploymentDirectory); + File.WriteAllText(staleTempFile, string.Empty); + File.WriteAllText(deploymentJournal, string.Empty); + + resolver.PrepareGameDirectories(paths, true); + + 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.TempDirectory).Should().BeTrue(); + Directory.Exists(paths.DeploymentDirectory).Should().BeTrue(); + Directory.Exists(paths.IntegrityDirectory).Should().BeTrue(); + Directory.Exists(paths.StateDirectory).Should().BeTrue(); + Directory.EnumerateFileSystemEntries(paths.TempDirectory).Should().BeEmpty(); + File.Exists(deploymentJournal).Should().BeTrue(); + } + + /// + /// Skipping the temporary cleanup is still a full preparation. The staging and deployment folders are created + /// nowhere else, so a caller that keeps existing temporary content still needs them on disk afterwards. + /// + [Fact] + public void PrepareGameDirectories_WithoutClearingTemporaryContent_StillCreatesTheIsolatedLayout() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = directory.CreateDirectory("Game"); + var resolver = new FileSystemLauncherPathResolver(); + LauncherStoragePaths storage = resolver.Resolve(executableDirectory); + resolver.PrepareLauncherDirectories(storage); + LauncherPaths paths = storage.CreateGamePaths(SupportedGame.Generals, gameDirectory); + + resolver.PrepareGameDirectories(paths, false); + + Directory.Exists(paths.TempDirectory).Should().BeTrue(); + Directory.Exists(paths.DeploymentDirectory).Should().BeTrue(); + Directory.Exists(paths.ModsDirectory).Should().BeTrue(); + Directory.Exists(paths.StateDirectory).Should().BeTrue(); + } + + /// + /// A download the launcher suspended on close leaves its partial content staged under the temporary packages + /// folder. Clearing that on the next startup is what would silently restart the transfer from zero. + /// + [Fact] + public void PrepareGameDirectories_KeepsStagedPackagesWhileClearingOtherTemporaryContent() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = directory.CreateDirectory("Game"); + var resolver = new FileSystemLauncherPathResolver(); + LauncherStoragePaths storage = resolver.Resolve(executableDirectory); + resolver.PrepareLauncherDirectories(storage); + LauncherPaths paths = storage.CreateGamePaths(SupportedGame.ZeroHour, gameDirectory); + string stagedPackageFile = Path.Combine(paths.PackagesDirectory, "Contra", "contra.big"); + string staleTempFile = Path.Combine(paths.TempDirectory, "scratch.tmp"); + Directory.CreateDirectory(Path.GetDirectoryName(stagedPackageFile)!); + Directory.CreateDirectory(paths.TempDirectory); + File.WriteAllText(stagedPackageFile, "partial"); + File.WriteAllText(staleTempFile, string.Empty); + + resolver.PrepareGameDirectories(paths, true); + + File.Exists(stagedPackageFile).Should().BeTrue(); + File.ReadAllText(stagedPackageFile).Should().Be("partial"); + File.Exists(staleTempFile).Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationRegistryTests.cs b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationRegistryTests.cs new file mode 100644 index 00000000..68744015 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationRegistryTests.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Startup; +using Microsoft.Win32; + +namespace GenLauncherGO.Tests.Infrastructure.Startup; + +public sealed class WindowsGameInstallationRegistryTests +{ + private const string GeneralsKey = + @"SOFTWARE\Electronic Arts\EA Games\Generals"; + + private const string ZeroHourEaKey = + @"SOFTWARE\Electronic Arts\EA Games\Command and Conquer Generals Zero Hour"; + + private const string ZeroHourSteamKey = + @"SOFTWARE\Electronic Arts\EA Games\ZeroHour"; + + private const string FirstDecadeKey = + @"SOFTWARE\Electronic Arts\EA Games\Command and Conquer The First Decade"; + + [Fact] + public void ReadCandidates_QueriesGeneralsSourcesInGeneralsGameCodeOrder() + { + var reads = new List<(RegistryView View, string KeyName, string ValueName)>(); + int candidateNumber = 0; + var registry = new WindowsGameInstallationRegistry((view, keyName, valueName) => + { + reads.Add((view, keyName, valueName)); + candidateNumber++; + return $@"C:\Candidate{candidateNumber}"; + }); + + IReadOnlyList candidates = registry.ReadCandidates(SupportedGame.Generals); + + reads.Should().Equal( + (RegistryView.Registry32, GeneralsKey, "InstallPath"), + (RegistryView.Registry64, GeneralsKey, "InstallPath"), + (RegistryView.Registry32, FirstDecadeKey, "gr_folder"), + (RegistryView.Registry64, FirstDecadeKey, "gr_folder"), + (RegistryView.Registry32, GeneralsKey, "installPath"), + (RegistryView.Registry64, GeneralsKey, "installPath")); + candidates.Should().Equal( + @"C:\Candidate1", + @"C:\Candidate2", + @"C:\Candidate3", + @"C:\Candidate4", + @"C:\Candidate5", + @"C:\Candidate6"); + } + + [Fact] + public void ReadCandidates_QueriesZeroHourSourcesInGeneralsGameCodeOrder() + { + var reads = new List<(RegistryView View, string KeyName, string ValueName)>(); + int candidateNumber = 0; + var registry = new WindowsGameInstallationRegistry((view, keyName, valueName) => + { + reads.Add((view, keyName, valueName)); + candidateNumber++; + return $@"C:\Candidate{candidateNumber}"; + }); + + IReadOnlyList candidates = registry.ReadCandidates(SupportedGame.ZeroHour); + + reads.Should().Equal( + (RegistryView.Registry32, ZeroHourEaKey, "InstallPath"), + (RegistryView.Registry64, ZeroHourEaKey, "InstallPath"), + (RegistryView.Registry32, FirstDecadeKey, "zh_folder"), + (RegistryView.Registry64, FirstDecadeKey, "zh_folder"), + (RegistryView.Registry32, ZeroHourSteamKey, "installPath"), + (RegistryView.Registry64, ZeroHourSteamKey, "installPath")); + candidates.Should().Equal( + @"C:\Candidate1", + @"C:\Candidate2", + @"C:\Candidate3", + @"C:\Candidate4", + @"C:\Candidate5", + @"C:\Candidate6"); + } + + [Fact] + public void ReadCandidates_PrefersZeroHourInstallRootOverSteamDataSubdirectory() + { + const string InstallRoot = @"C:\Steam\Command & Conquer Generals - Zero Hour"; + const string SteamDataDirectory = + @"C:\Steam\Command & Conquer Generals - Zero Hour\ZH_Generals"; + var registry = new WindowsGameInstallationRegistry((_, keyName, _) => keyName switch + { + ZeroHourEaKey => InstallRoot, + ZeroHourSteamKey => SteamDataDirectory, + _ => null + }); + + IReadOnlyList candidates = registry.ReadCandidates(SupportedGame.ZeroHour); + + candidates.Should().Equal(InstallRoot, SteamDataDirectory); + } + + [Fact] + public void ReadCandidates_DeduplicatesEquivalentPathsWithoutChangingPriority() + { + string[] values = + [ + "\"C:\\Steam\\Zero Hour\\\"", + @"C:\Steam\Zero Hour", + @"C:\EA\Zero Hour", + @"c:\ea\zero hour", + @"C:\Retail\Zero Hour", + string.Empty + ]; + int readIndex = 0; + var registry = new WindowsGameInstallationRegistry((_, _, _) => values[readIndex++]); + + IReadOnlyList candidates = registry.ReadCandidates(SupportedGame.ZeroHour); + + candidates.Should().Equal( + @"C:\Steam\Zero Hour", + @"C:\EA\Zero Hour", + @"C:\Retail\Zero Hour"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationServiceTests.cs new file mode 100644 index 00000000..04d00428 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationServiceTests.cs @@ -0,0 +1,452 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Core.Startup.Models; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Startup; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Startup; + +public sealed class WindowsGameInstallationServiceTests +{ + [Theory] + [InlineData(SupportedGame.Generals, LauncherFileSystemLayout.GeneralsCommunityExecutableFileName)] + [InlineData(SupportedGame.Generals, LauncherFileSystemLayout.RetailGameExecutableFileName)] + [InlineData(SupportedGame.ZeroHour, LauncherFileSystemLayout.GeneralsOnlineExecutableFileName)] + [InlineData(SupportedGame.ZeroHour, LauncherFileSystemLayout.ZeroHourCommunityExecutableFileName)] + [InlineData(SupportedGame.ZeroHour, LauncherFileSystemLayout.RetailGameExecutableFileName)] + public void Validate_AcceptsRootWithMatchingBuiltInExecutable( + SupportedGame game, + string executableName) + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = CreateDirectoryWithExecutable(directory, "Game", executableName); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(game, gameDirectory, executableDirectory); + + result.IsValid.Should().BeTrue(); + result.Failure.Should().Be(GameInstallationValidationFailure.None); + result.CanonicalPath.Should().Be(PhysicalDirectoryPath.ResolveExisting(gameDirectory)); + } + + [Theory] + [InlineData(SupportedGame.Generals)] + [InlineData(SupportedGame.ZeroHour)] + public void Validate_RejectsRootWithoutBuiltInExecutable(SupportedGame game) + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = directory.CreateDirectory("Game"); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(game, gameDirectory, executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.BuiltInExecutableNotFound); + } + + [Theory] + [InlineData(SupportedGame.Generals, LauncherFileSystemLayout.ZeroHourCommunityExecutableFileName)] + [InlineData(SupportedGame.ZeroHour, LauncherFileSystemLayout.GeneralsCommunityExecutableFileName)] + public void Validate_RejectsBuiltInExecutableForDifferentGame( + SupportedGame game, + string executableName) + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = CreateDirectoryWithExecutable(directory, "Game", executableName); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(game, gameDirectory, executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.BuiltInExecutableNotFound); + } + + [SymbolicLinkFact] + public void Validate_RejectsBuiltInExecutableReachedThroughSymbolicLink() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = directory.CreateDirectory("Game"); + string targetPath = directory.CreateFile("target.exe", string.Empty); + SymbolicLinkTestSupport.CreateFileLink( + Path.Combine(gameDirectory, LauncherFileSystemLayout.GeneralsCommunityExecutableFileName), + targetPath); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.Generals, gameDirectory, executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.BuiltInExecutableNotFound); + } + + [Fact] + public void ValidateInstallations_TreatsQuotedAndUnquotedDirectoryAsDuplicate() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = CreateDirectoryWithExecutable( + directory, + "Game", + LauncherFileSystemLayout.RetailGameExecutableFileName); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + LauncherInstallationsValidationResult result = service.ValidateInstallations( + new LauncherInstallations + { + Generals = gameDirectory, + ZeroHour = $"\"{gameDirectory}\"" + }, + executableDirectory); + + result.GeneralsValidation.IsValid.Should().BeTrue(); + result.ZeroHourValidation.IsValid.Should().BeTrue(); + result.HasDuplicatePath.Should().BeTrue(); + } + + [Fact] + public void Validate_RejectsExecutableInsideGameInstallation() + { + using var directory = new TestDirectory(); + string gameDirectory = directory.CreateDirectory("Game"); + string executableDirectory = Directory.CreateDirectory( + Path.Combine(gameDirectory, "Launcher")).FullName; + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.ZeroHour, gameDirectory, executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.LauncherLocationOverlapsGame); + } + + [Fact] + public void Validate_AcceptsGameInstallationBelowExecutableDirectoryWhenOutsideLauncherData() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = CreateDirectoryWithExecutable( + directory, + Path.Combine("Launcher", "Game"), + LauncherFileSystemLayout.GeneralsCommunityExecutableFileName); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.Generals, gameDirectory, executableDirectory); + + result.IsValid.Should().BeTrue(); + result.CanonicalPath.Should().Be(PhysicalDirectoryPath.ResolveExisting(gameDirectory)); + } + + [Fact] + public void Validate_RejectsGameInstallationInsideLauncherOwnedData() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = directory.CreateDirectory( + Path.Combine("Launcher", LauncherFileSystemLayout.LauncherDataFolderName, "Game")); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.Generals, gameDirectory, executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.UnsafeFileSystemPath); + } + + [Fact] + public void Validate_RejectsInstallationReachedThroughReparsePoint() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string linkedDirectory = directory.GetPath("LinkedGame"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + linkedDirectory, + "RealGame"); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.Generals, linkedDirectory, executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.UnsafeFileSystemPath); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// The launcher writes its owned data below its own directory, so a launcher location reached through a link is + /// exactly as unsafe as a linked game directory. + /// + [Fact] + public void Validate_RejectsLauncherLocationReachedThroughReparsePoint() + { + using var directory = new TestDirectory(); + string gameDirectory = CreateDirectoryWithExecutable( + directory, + "Game", + LauncherFileSystemLayout.GeneralsCommunityExecutableFileName); + string linkedExecutableDirectory = directory.GetPath("LinkedLauncher"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + linkedExecutableDirectory, + "RealLauncher"); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.Generals, gameDirectory, linkedExecutableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.UnsafeFileSystemPath); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// Setup shows a message for every validation failure, so a directory value Windows cannot even turn into a + /// path has to arrive as an actionable result instead of an exception. + /// + [Fact] + public void Validate_UnusableDirectoryValue_ReportsPathUnavailable() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.ZeroHour, "C:\\Games\0Zero Hour", executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.PathUnavailable); + } + + /// + /// Both supplied paths are inspected for safety, and neither inspection may escape as an exception. + /// + [Fact] + public void Validate_UnusableExecutableDirectoryValue_ReportsPathUnavailable() + { + using var directory = new TestDirectory(); + string gameDirectory = CreateDirectoryWithExecutable( + directory, + "Game", + LauncherFileSystemLayout.ZeroHourCommunityExecutableFileName); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.ZeroHour, gameDirectory, "C:\\Launcher\0Data"); + + result.Failure.Should().Be(GameInstallationValidationFailure.PathUnavailable); + } + + /// + /// The launcher folder can be moved or deleted while the launcher runs. Canonicalizing it then fails, and + /// setup has to report that rather than propagate the failure. + /// + [Fact] + public void Validate_MissingExecutableDirectory_ReportsPathUnavailable() + { + using var directory = new TestDirectory(); + string gameDirectory = CreateDirectoryWithExecutable( + directory, + "Game", + LauncherFileSystemLayout.ZeroHourCommunityExecutableFileName); + string missingExecutableDirectory = directory.GetPath("RemovedLauncher"); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.ZeroHour, gameDirectory, missingExecutableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.PathUnavailable); + } + + /// + /// A session without a detected game carries . Validating against it has no + /// meaning, so it is rejected instead of silently producing a failure result callers would show to the user. + /// + [Fact] + public void Validate_UnsupportedGame_IsRejected() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = CreateDirectoryWithExecutable( + directory, + "Game", + LauncherFileSystemLayout.RetailGameExecutableFileName); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + Action act = () => service.Validate(SupportedGame.Unknown, gameDirectory, executableDirectory); + + act.Should().Throw(); + } + + [Fact] + public void DiscoverValidInstallations_NeverOverwritesValidConfiguredPath() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string configuredDirectory = CreateDirectoryWithExecutable( + directory, + "ConfiguredGenerals", + LauncherFileSystemLayout.GeneralsCommunityExecutableFileName); + string registryDirectory = CreateDirectoryWithExecutable( + directory, + "RegistryGenerals", + LauncherFileSystemLayout.GeneralsCommunityExecutableFileName); + var registry = new FakeRegistry(); + registry.Add(SupportedGame.Generals, registryDirectory); + WindowsGameInstallationService service = CreateService(registry); + var current = new LauncherInstallations { Generals = configuredDirectory }; + + LauncherInstallations discovered = + service.DiscoverValidInstallations(current, executableDirectory); + + discovered.Generals.Should().Be(configuredDirectory); + } + + [Fact] + public void DiscoverValidInstallations_SkipsMissingRegistryCandidateAndFillsMissingPath() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string missingDirectory = directory.GetPath("Missing"); + string validDirectory = CreateDirectoryWithExecutable( + directory, + "ZeroHour", + LauncherFileSystemLayout.ZeroHourCommunityExecutableFileName); + var registry = new FakeRegistry(); + registry.Add(SupportedGame.ZeroHour, missingDirectory, validDirectory); + WindowsGameInstallationService service = CreateService(registry); + + LauncherInstallations discovered = + service.DiscoverValidInstallations(new LauncherInstallations(), executableDirectory); + + discovered.ZeroHour.Should().Be(PhysicalDirectoryPath.ResolveExisting(validDirectory)); + discovered.Generals.Should().BeNull(); + } + + [Fact] + public void DiscoverValidInstallations_UsesFirstValidRegistryCandidate() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string steamDirectory = CreateDirectoryWithExecutable( + directory, + "SteamZeroHour", + LauncherFileSystemLayout.RetailGameExecutableFileName); + string eaDirectory = CreateDirectoryWithExecutable( + directory, + "EaZeroHour", + LauncherFileSystemLayout.RetailGameExecutableFileName); + var registry = new FakeRegistry(); + registry.Add(SupportedGame.ZeroHour, steamDirectory, eaDirectory); + WindowsGameInstallationService service = CreateService(registry); + + LauncherInstallations discovered = + service.DiscoverValidInstallations(new LauncherInstallations(), executableDirectory); + + discovered.ZeroHour.Should().Be(PhysicalDirectoryPath.ResolveExisting(steamDirectory)); + } + + /// + /// A retail directory satisfies both games, and discovery walks Zero Hour first. Filling both entries with one + /// directory would leave the launcher deploying Generals content into a Zero Hour installation. + /// + [Fact] + public void DiscoverValidInstallations_CandidateValidForBothGames_FillsOnlyZeroHour() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string sharedDirectory = CreateDirectoryWithExecutable( + directory, + "Retail", + LauncherFileSystemLayout.RetailGameExecutableFileName); + var registry = new FakeRegistry(); + registry.Add(SupportedGame.ZeroHour, sharedDirectory); + registry.Add(SupportedGame.Generals, sharedDirectory); + WindowsGameInstallationService service = CreateService(registry); + + LauncherInstallations discovered = + service.DiscoverValidInstallations(new LauncherInstallations(), executableDirectory); + + discovered.ZeroHour.Should().Be(PhysicalDirectoryPath.ResolveExisting(sharedDirectory)); + discovered.Generals.Should().BeNull(); + } + + [Fact] + public void DiscoverValidInstallations_ConfiguredPathAlsoOfferedToTheOtherGame_KeepsTheOtherGameEmpty() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string sharedDirectory = CreateDirectoryWithExecutable( + directory, + "Retail", + LauncherFileSystemLayout.RetailGameExecutableFileName); + var registry = new FakeRegistry(); + registry.Add(SupportedGame.Generals, sharedDirectory); + WindowsGameInstallationService service = CreateService(registry); + var current = new LauncherInstallations { ZeroHour = sharedDirectory }; + + LauncherInstallations discovered = + service.DiscoverValidInstallations(current, executableDirectory); + + discovered.ZeroHour.Should().Be(sharedDirectory); + discovered.Generals.Should().BeNull(); + } + + [Fact] + public void FindContainingInstallation_DetectsGameBeforeStandaloneStorageIsCreated() + { + using var directory = new TestDirectory(); + string gameDirectory = CreateDirectoryWithExecutable( + directory, + "ZeroHour", + "generalszh.exe"); + string executableDirectory = directory.CreateDirectory( + Path.Combine("ZeroHour", "Tools", "GenLauncherGO")); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationLocation? result = + service.FindContainingInstallation(executableDirectory); + + result.Should().NotBeNull(); + result!.Game.Should().Be(SupportedGame.ZeroHour); + result.Directory.Should().Be(PhysicalDirectoryPath.ResolveExisting(gameDirectory)); + } + + private static WindowsGameInstallationService CreateService(IGameInstallationRegistry registry) + { + return new WindowsGameInstallationService( + registry, + NullLogger.Instance); + } + + private static string CreateDirectoryWithExecutable( + TestDirectory directory, + string relativeDirectory, + string executableName) + { + string gameDirectory = directory.CreateDirectory(relativeDirectory); + File.WriteAllText(Path.Combine(gameDirectory, executableName), string.Empty); + return gameDirectory; + } + + private sealed class FakeRegistry : IGameInstallationRegistry + { + private readonly Dictionary> _candidates = []; + + public IReadOnlyList ReadCandidates(SupportedGame game) + { + return _candidates.TryGetValue(game, out IReadOnlyList? candidates) + ? candidates + : Array.Empty(); + } + + public void Add(SupportedGame game, params string[] candidates) + { + _candidates[game] = candidates; + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs new file mode 100644 index 00000000..6b07cf3e --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Infrastructure.Startup; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Startup; + +public sealed class WindowsLauncherHostEnvironmentServiceTests +{ + /// + /// Every launcher-owned path is resolved against this directory, so it has to be the folder the running + /// executable lives in rather than any other directory the process happens to be able to name. + /// + [Fact] + public void GetExecutableDirectory_ReturnsTheDirectoryHoldingTheRunningExecutable() + { + var service = new WindowsLauncherHostEnvironmentService(); + string runningExecutableDirectory = Path.GetDirectoryName(Environment.ProcessPath!)!; + + string directory = service.GetExecutableDirectory(); + + directory.Should().Be(runningExecutableDirectory); + Directory.Exists(directory).Should().BeTrue(); + } + + [Fact] + public void TryAcquireSingleInstance_ReturnsAcquiredGuardForUnusedName() + { + var service = new WindowsLauncherHostEnvironmentService(); + string instanceName = CreateInstanceName(); + + using ILauncherSingleInstanceGuard guard = service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + guard.IsAcquired.Should().BeTrue(); + } + + [Fact] + public async Task TryAcquireSingleInstance_ReturnsAcquiredGuardWhenNameIsReleasedBeforeRetryAsync() + { + string instanceName = CreateInstanceName(); + using ManualResetEventSlim mutexAcquired = new(); + using ManualResetEventSlim releaseMutex = new(); + using ManualResetEventSlim retryStarted = new(); + using ManualResetEventSlim allowRetry = new(); + var service = new WindowsLauncherHostEnvironmentService( + NullLogger.Instance, + _ => + { + retryStarted.Set(); + allowRetry.Wait(); + }); + Exception? ownerException = null; + Thread ownerThread = new(() => + { + try + { + using Mutex owner = new(true, instanceName, out _); + mutexAcquired.Set(); + releaseMutex.Wait(); + owner.ReleaseMutex(); + } + catch (Exception exception) + { + ownerException = exception; + mutexAcquired.Set(); + } + }) + { + IsBackground = true + }; + + ownerThread.Start(); + try + { + mutexAcquired.Wait(TestTimeouts.Wait, TestContext.Current.CancellationToken).Should().BeTrue(); + + Task acquisition = Task.Run(() => + service.TryAcquireSingleInstance(instanceName, TimeSpan.FromMilliseconds(100))); + retryStarted.Wait(TestTimeouts.Wait, TestContext.Current.CancellationToken).Should().BeTrue(); + + releaseMutex.Set(); + ownerThread.Join(); + allowRetry.Set(); + using ILauncherSingleInstanceGuard guard = + await acquisition.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + + ownerException.Should().BeNull(); + guard.IsAcquired.Should().BeTrue(); + } + finally + { + releaseMutex.Set(); + allowRetry.Set(); + ownerThread.Join(TestTimeouts.Wait); + } + } + + [Fact] + public void TryAcquireSingleInstance_ReturnsRejectedGuardWhenNameIsAlreadyOwned() + { + var service = new WindowsLauncherHostEnvironmentService(); + string instanceName = CreateInstanceName(); + using ILauncherSingleInstanceGuard firstGuard = service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + using ILauncherSingleInstanceGuard secondGuard = service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + firstGuard.IsAcquired.Should().BeTrue(); + secondGuard.IsAcquired.Should().BeFalse(); + } + + /// + /// A zero retry delay asks for an immediate second attempt. Waiting anyway would stall startup for a launcher + /// that explicitly opted out of the wait. + /// + [Fact] + public void TryAcquireSingleInstance_ZeroRetryDelay_RetriesWithoutWaiting() + { + var requestedWaits = new List(); + var service = new WindowsLauncherHostEnvironmentService( + NullLogger.Instance, + requestedWaits.Add); + string instanceName = CreateInstanceName(); + using ILauncherSingleInstanceGuard firstGuard = service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + using ILauncherSingleInstanceGuard secondGuard = service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + secondGuard.IsAcquired.Should().BeFalse(); + requestedWaits.Should().BeEmpty(); + } + + /// + /// Neither the guard that owns the instance name nor a rejected attempt may keep it reserved after they are + /// released; otherwise closing the running launcher would still block the next one from starting. + /// + [Fact] + public void TryAcquireSingleInstance_AfterHoldingAndRejectedGuardsAreReleased_AllowsANewGuard() + { + var service = new WindowsLauncherHostEnvironmentService(); + string instanceName = CreateInstanceName(); + using (service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero)) + { + service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero).Dispose(); + } + + using ILauncherSingleInstanceGuard replacementGuard = + service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + replacementGuard.IsAcquired.Should().BeTrue(); + } + + [Fact] + public void IsProtectedProgramFilesDirectory_ReturnsFalseForTemporaryDirectory() + { + var service = new WindowsLauncherHostEnvironmentService(); + + bool result = service.IsProtectedProgramFilesDirectory(Path.GetTempPath()); + + result.Should().BeFalse(); + } + + /// + /// Both Program Files roots are protected, so an installation under either one has to be recognized on its own. + /// + [Theory] + [InlineData(Environment.SpecialFolder.ProgramFiles)] + [InlineData(Environment.SpecialFolder.ProgramFilesX86)] + public void IsProtectedProgramFilesDirectory_ProgramFilesRoot_ReturnsTrue(Environment.SpecialFolder programFiles) + { + var service = new WindowsLauncherHostEnvironmentService(); + string programFilesPath = Environment.GetFolderPath(programFiles); + if (string.IsNullOrWhiteSpace(programFilesPath)) + { + return; + } + + bool rootIsProtected = service.IsProtectedProgramFilesDirectory(programFilesPath); + bool installationIsProtected = + service.IsProtectedProgramFilesDirectory(Path.Combine(programFilesPath, "Game")); + + rootIsProtected.Should().BeTrue(); + installationIsProtected.Should().BeTrue(); + } + + private static string CreateInstanceName() + { + return "GenLauncherGO.Tests." + Guid.NewGuid().ToString("N"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Clients/HttpDownloadFileMetadataReaderTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/HttpDownloadFileMetadataReaderTests.cs new file mode 100644 index 00000000..9307a03b --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/HttpDownloadFileMetadataReaderTests.cs @@ -0,0 +1,153 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Clients; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Clients; + +public sealed class HttpDownloadFileMetadataReaderTests +{ + [Fact] + public async Task ReadMetadataAsync_UsesHeadContentDispositionFileNameStarAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse( + HttpStatusCode.OK, + "attachment; filename*=UTF-8''Folder%20Package.big", + 123)); + HttpDownloadFileMetadataReader reader = CreateReader(handler); + Uri uri = new("https://example.test/packages/package.big"); + + DownloadFileMetadata metadata = await reader.ReadMetadataAsync(uri, CancellationToken.None); + + metadata.DownloadUri.Should().Be(uri); + metadata.FileName.Should().Be("Folder Package.big"); + metadata.TotalBytes.Should().Be(123); + handler.Methods.Should().Equal(HttpMethod.Head); + } + + /// + /// The star form carries the encoded name a server sends alongside an ASCII fallback, so it wins whenever both + /// are present. + /// + [Fact] + public async Task ReadMetadataAsync_ContentDispositionCarriesBothFileNameForms_PrefersFileNameStarAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse( + HttpStatusCode.OK, + "attachment; filename=\"ascii.zip\"; filename*=UTF-8''unicode.zip")); + HttpDownloadFileMetadataReader reader = CreateReader(handler); + + DownloadFileMetadata metadata = await reader.ReadMetadataAsync( + new Uri("https://example.test/package"), + CancellationToken.None); + + metadata.FileName.Should().Be("unicode.zip"); + } + + [Theory] + [InlineData(HttpStatusCode.MethodNotAllowed)] + [InlineData(HttpStatusCode.NotImplemented)] + public async Task ReadMetadataAsync_FallsBackToGetWhenHeadIsNotAllowedAsync(HttpStatusCode headStatusCode) + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(headStatusCode)); + handler.Enqueue(_ => CreateResponse( + HttpStatusCode.OK, + "attachment; filename=\"Package.zip\"")); + HttpDownloadFileMetadataReader reader = CreateReader(handler); + + DownloadFileMetadata metadata = await reader.ReadMetadataAsync( + new Uri("https://example.test/package.zip"), + CancellationToken.None); + + metadata.FileName.Should().Be("Package.zip"); + handler.Methods.Should().Equal(HttpMethod.Head, HttpMethod.Get); + } + + /// + /// Only the two "method unsupported" statuses justify a second request; any other failure is the server's answer + /// and must surface instead of being retried as a GET. + /// + [Fact] + public async Task ReadMetadataAsync_HeadFailsWithServerError_ThrowsWithoutFallbackRequestAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.InternalServerError)); + HttpDownloadFileMetadataReader reader = CreateReader(handler); + + Func act = () => reader.ReadMetadataAsync( + new Uri("https://example.test/package.zip"), + CancellationToken.None); + + await act.Should().ThrowAsync(); + handler.Requests.Should().ContainSingle(); + } + + [Fact] + public async Task ReadMetadataAsync_ThrowsWhenNeitherRequestReturnsFileNameAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK)); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK)); + HttpDownloadFileMetadataReader reader = CreateReader(handler); + + Func act = () => reader.ReadMetadataAsync( + new Uri("https://example.test/package"), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("Download link is incorrect, please contact modification creator and try again later."); + handler.Methods.Should().Equal(HttpMethod.Head, HttpMethod.Get); + } + + [Theory] + [InlineData("attachment; filename=\"\\\\\"")] + [InlineData("attachment; filename*=UTF-8''Folder%2FPackage.big")] + [InlineData("attachment; filename=\"CON\"")] + [InlineData("attachment; filename=\"Package.zip.\"")] + public async Task ReadMetadataAsync_ThrowsWhenRemoteFileNameIsUnsafeAsync(string contentDisposition) + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse( + HttpStatusCode.OK, + contentDisposition)); + HttpDownloadFileMetadataReader reader = CreateReader(handler); + + Func act = () => reader.ReadMetadataAsync( + new Uri("https://example.test/package"), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("Download link is incorrect, please contact modification creator and try again later."); + handler.Methods.Should().Equal(HttpMethod.Head); + } + + private static HttpDownloadFileMetadataReader CreateReader(QueueHttpMessageHandler handler) + { + return new HttpDownloadFileMetadataReader(new HttpClient(handler)); + } + + private static HttpResponseMessage CreateResponse( + HttpStatusCode statusCode, + string? contentDisposition = null, + long? contentLength = null) + { + HttpResponseMessage response = new(statusCode) + { + Content = new ByteArrayContent([]) + }; + if (contentDisposition is not null) + { + response.Content.Headers.ContentDisposition = ContentDispositionHeaderValue.Parse(contentDisposition); + } + + response.Content.Headers.ContentLength = contentLength; + return response; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioClientFactoryTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioClientFactoryTests.cs new file mode 100644 index 00000000..6f19912d --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioClientFactoryTests.cs @@ -0,0 +1,24 @@ +using Minio; +using Subject = GenLauncherGO.Infrastructure.Updating.Clients.MinioClientFactory; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Clients; + +public sealed class MinioClientFactoryTests +{ + [Theory] + [InlineData("s3.example.test", false, "http://s3.example.test", false)] + [InlineData("http://s3.example.test:9000/path", true, "http://s3.example.test:9000", false)] + [InlineData("https://s3.example.test/path", false, "https://s3.example.test", true)] + [InlineData("s3.example.test:443", false, "https://s3.example.test:443", true)] + public void Create_NormalizesEndpointAndResolvesTransportSecurity( + string endpoint, + bool useSsl, + string expectedEndpoint, + bool expectedSecure) + { + IMinioClient client = Subject.Create(endpoint, "access", "secret", useSsl); + + client.Config.Endpoint.Should().Be(expectedEndpoint); + client.Config.Secure.Should().Be(expectedSecure); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioS3ObjectManifestReaderTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioS3ObjectManifestReaderTests.cs new file mode 100644 index 00000000..d8e6a239 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioS3ObjectManifestReaderTests.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Clients; +using GenLauncherGO.Infrastructure.Updating.Models; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Clients; + +public sealed class MinioS3ObjectManifestReaderTests +{ + [Theory] + [InlineData("ShockWave/1.2")] + [InlineData("ShockWave/1.2/")] + public async Task ReadManifestAsync_ReturnsPrefixRelativeEntriesAsync(string prefix) + { + S3ObjectManifestRequest? receivedRequest = null; + using CancellationTokenSource cancellationTokenSource = new(); + S3ObjectManifestRequest request = CreateRequest(prefix); + MinioS3ObjectManifestReader reader = new( + NullLogger.Instance, + (manifestRequest, cancellationToken) => + { + receivedRequest = manifestRequest; + cancellationToken.Should().Be(cancellationTokenSource.Token); + return EnumerateObjectsAsync( + new MinioS3ObjectManifestReader.S3ObjectManifestItem( + "ShockWave/1.2/files/launcher.big", + " \"ABC123\" ", + 42), + new MinioS3ObjectManifestReader.S3ObjectManifestItem( + "outside-prefix.big", + "DEF456", + 7)); + }); + + IReadOnlyList entries = await reader.ReadManifestAsync( + request, + cancellationTokenSource.Token); + + entries.Should().Equal( + new RemoteFileManifestEntry("files/launcher.big", "ABC123", 42), + new RemoteFileManifestEntry("outside-prefix.big", "DEF456", 7)); + receivedRequest.Should().BeSameAs(request); + } + + private static S3ObjectManifestRequest CreateRequest(string prefix = "ShockWave") + { + return new S3ObjectManifestRequest( + "s3.example.test", + "mods", + prefix, + "access", + "secret"); + } + + private static async IAsyncEnumerable EnumerateObjectsAsync( + params MinioS3ObjectManifestReader.S3ObjectManifestItem[] items) + { + await Task.Yield(); + + foreach (MinioS3ObjectManifestReader.S3ObjectManifestItem item in items) + { + yield return item; + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Clients/ResumableHttpFileDownloaderTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/ResumableHttpFileDownloaderTests.cs new file mode 100644 index 00000000..16956111 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/ResumableHttpFileDownloaderTests.cs @@ -0,0 +1,1025 @@ +using System; +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.Models; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Clients; + +public sealed class ResumableHttpFileDownloaderTests +{ + [Theory] + [InlineData("RelativeUri")] + [InlineData("UnsupportedScheme")] + [InlineData("MissingDestination")] + public async Task DownloadFileAsync_ThrowsForInvalidRequestAsync(string invalidRequest) + { + ResumableHttpFileDownloader downloader = CreateDownloader(new QueueHttpMessageHandler()); + DownloadFileRequest request = invalidRequest switch + { + "RelativeUri" => new DownloadFileRequest(new Uri("mod.zip", UriKind.Relative), "mod.zip"), + "UnsupportedScheme" => new DownloadFileRequest(new Uri("ftp://example.test/mod.zip"), "mod.zip"), + "MissingDestination" => new DownloadFileRequest(new Uri("https://example.test/mod.zip"), " "), + _ => throw new ArgumentOutOfRangeException(nameof(invalidRequest), invalidRequest, null) + }; + string expectedParameterName = invalidRequest == "MissingDestination" + ? "request.DestinationFilePath" + : "request"; + + Func act = () => downloader.DownloadFileAsync(request, null, CancellationToken.None); + + await act.Should().ThrowAsync().WithParameterName(expectedParameterName); + } + + [Fact] + public async Task DownloadFileAsync_WritesResponseBodyToDestinationAsync() + { + 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(); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.zip"), destinationFilePath), + progress, + CancellationToken.None); + + (await File.ReadAllBytesAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Equal(payload); + progress.Reports.Should().Contain(report => report.BytesDownloaded == payload.Length); + } + + /// + /// The destination is normalized before the transfer starts, so a caller-supplied parent segment resolves to one + /// real folder instead of materializing the segment it walks out of. + /// + [Fact] + public async Task DownloadFileAsync_NormalizesDestinationBeforeCreatingItsFolderAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "a", "..", "nested", "mod.zip"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes("payload"))); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.zip"), destinationFilePath), + null, + CancellationToken.None); + + (await File.ReadAllTextAsync(Path.Combine(testDirectory.Path, "nested", "mod.zip"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + Directory.Exists(Path.Combine(testDirectory.Path, "a")).Should().BeFalse(); + } + + [Fact] + public async Task DownloadFileAsync_ResumesExistingPartialFileWithRangeRequestAsync() + { + 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, TestContext.Current.CancellationToken); + + 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); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath, 6), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().Be("bytes=3-"); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("abcdef"); + } + + /// + /// A range response states the object's real length, so it settles the transfer size even when the caller was + /// working from a stale expectation. + /// + [Fact] + public async Task DownloadFileAsync_ResumedResponseDeclaresTotal_PrefersContentRangeLengthAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "abc", TestContext.Current.CancellationToken); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => + { + HttpResponseMessage response = CreateResponse( + HttpStatusCode.PartialContent, + Encoding.UTF8.GetBytes("def")); + response.Content.Headers.ContentRange = new ContentRangeHeaderValue(3, 5, 6); + return response; + }); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + RecordingProgress progress = new(); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath, 7), + progress, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().Be("bytes=3-"); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("abcdef"); + progress.Reports.Last().Should().Be(new DownloadProgress(6, 6, 100)); + } + + [Fact] + public async Task DownloadFileAsync_RestartsWhenServerIgnoresRangeRequestAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.zip"); + await File.WriteAllTextAsync(destinationFilePath, "partial", TestContext.Current.CancellationToken); + + byte[] fullPayload = Encoding.UTF8.GetBytes("fresh"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, fullPayload)); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.zip"), destinationFilePath), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().Be("bytes=7-"); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("fresh"); + } + + [Fact] + public async Task DownloadFileAsync_RestartsWhenServerReturnsUnexpectedContentRangeAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "abc", TestContext.Current.CancellationToken); + + 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); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath, 6), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().Equal("bytes=3-", null); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("abcdef"); + } + + [Fact] + public async Task DownloadFileAsync_ReturnsExistingCompleteFileWithoutRequestAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "ready", TestContext.Current.CancellationToken); + QueueHttpMessageHandler handler = new(); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + RecordingProgress progress = new(); + + await downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + 5), + progress, + CancellationToken.None); + + handler.RangeHeaders.Should().BeEmpty(); + progress.Reports.Should().ContainSingle(report => + report.TotalBytes == 5 && + report.BytesDownloaded == 5 && + report.ProgressPercentage == 100); + } + + [Fact] + public async Task DownloadFileAsync_MissingExpectedEmptyFile_CreatesItFromResponseAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "placeholder.txt"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, [])); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/placeholder.txt"), + destinationFilePath, + 0), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().BeNull(); + File.Exists(destinationFilePath).Should().BeTrue(); + new FileInfo(destinationFilePath).Length.Should().Be(0); + } + + [Fact] + public async Task DownloadFileAsync_ExistingExpectedEmptyFile_ReturnsWithoutRequestAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "placeholder.txt"); + await File.WriteAllBytesAsync(destinationFilePath, [], TestContext.Current.CancellationToken); + QueueHttpMessageHandler handler = new(); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/placeholder.txt"), + destinationFilePath, + 0), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().BeEmpty(); + File.Exists(destinationFilePath).Should().BeTrue(); + new FileInfo(destinationFilePath).Length.Should().Be(0); + } + + [Fact] + public async Task DownloadFileAsync_ExistingFileLargerThanExpected_RestartsFromZeroAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "too-large", TestContext.Current.CancellationToken); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes("fresh"))); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + 5), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().BeNull(); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("fresh"); + } + + [Fact] + public async Task DownloadFileAsync_ReportsProgressWhileContentIsDownloadingAsync() + { + byte[] payload = Encoding.UTF8.GetBytes("abcdef"); + ManualTimeProvider timeProvider = new(); + 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 ChunkStream( + payload, + 2, + () => timeProvider.Advance(TimeSpan.FromMilliseconds(2)))) + }; + response.Content.Headers.ContentLength = payload.Length; + return response; + }); + ResumableHttpFileDownloader downloader = CreateDownloader(handler, timeProvider: timeProvider); + RecordingProgress progress = new(); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + progress, + CancellationToken.None); + + 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(); + } + + /// + /// Intermediate progress is rate limited, so a chunked transfer publishes far fewer reports than it reads + /// chunks while the opening and final positions still reach the caller. + /// + [Fact] + public async Task DownloadFileAsync_ThrottlesIntermediateProgressToTheReportIntervalAsync() + { + byte[] payload = Encoding.UTF8.GetBytes("abcdef"); + ManualTimeProvider timeProvider = new(); + 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 ChunkStream( + payload, + 1, + () => timeProvider.Advance(TimeSpan.FromMilliseconds(2)))) + }; + response.Content.Headers.ContentLength = payload.Length; + return response; + }); + ResumableHttpFileDownloader downloader = CreateDownloader( + handler, + progressReportInterval: TimeSpan.FromMilliseconds(10), + timeProvider: timeProvider); + RecordingProgress progress = new(); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + progress, + CancellationToken.None); + + long[] reportedBytes = progress.Reports.Select(report => report.BytesDownloaded).ToArray(); + reportedBytes.Should().StartWith(0); + reportedBytes.Should().EndWith(payload.Length); + reportedBytes.Should().HaveCountLessThan(payload.Length); + } + + [Fact] + public async Task DownloadFileAsync_PauseStopsTransferUntilResumedAsync() + { + byte[] payload = Encoding.UTF8.GetBytes("abcdef"); + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + SignalingStreamContent responseContent = new(payload); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = responseContent }); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + PackageDownloadPauseController pauseController = new(); + pauseController.Pause().Should().BeTrue(); + + Task download = downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + PauseController: pauseController), + null, + CancellationToken.None); + await responseContent.BodyRequested.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + + download.IsCompleted.Should().BeFalse(); + new FileInfo(destinationFilePath).Length.Should().Be(0); + + pauseController.Resume().Should().BeTrue(); + await download.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + + (await File.ReadAllBytesAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Equal(payload); + } + + [Fact] + public async Task DownloadFileAsync_CancellationInterruptsPausedTransferAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + SignalingStreamContent responseContent = new(Encoding.UTF8.GetBytes("abcdef")); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = responseContent }); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + PackageDownloadPauseController pauseController = new(); + pauseController.Pause(); + using CancellationTokenSource cancellation = new(); + + Task download = downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + PauseController: pauseController), + null, + cancellation.Token); + await responseContent.BodyRequested.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + await cancellation.CancelAsync(); + + Func act = () => download; + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DownloadFileAsync_ResumesBytesWrittenBeforeTransientResponseFailureAsync() + { + byte[] partialPayload = Encoding.UTF8.GetBytes("abc"); + byte[] remainingPayload = Encoding.UTF8.GetBytes("def"); + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new FailingAfterPayloadStream(partialPayload)) + }); + handler.Enqueue(_ => + { + HttpResponseMessage response = CreateResponse(HttpStatusCode.PartialContent, remainingPayload); + response.Content.Headers.ContentRange = new ContentRangeHeaderValue(3, 5, 6); + return response; + }); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + 6), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().Equal(null, "bytes=3-"); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("abcdef"); + } + + [Fact] + public async Task DownloadFileAsync_CancellationRetainsWrittenPrefixForResumeAsync() + { + byte[] partialPayload = Encoding.UTF8.GetBytes("abc"); + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + WaitingAfterPayloadStream responseStream = new(partialPayload); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(responseStream) + }); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + using CancellationTokenSource cancellation = new(); + + Task download = downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + 6), + null, + cancellation.Token); + await responseStream.WaitingForCancellation.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + await cancellation.CancelAsync(); + + Func act = () => download; + await act.Should().ThrowAsync(); + handler.RangeHeaders.Should().ContainSingle().Which.Should().BeNull(); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("abc"); + } + + [Fact] + public async Task DownloadFileAsync_ThrowsAfterFinalRetriableFailureAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => throw new HttpRequestException("offline")); + handler.Enqueue(_ => throw new HttpRequestException("still offline")); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + Func act = () => downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + null, + CancellationToken.None); + + IOException exception = (await act.Should().ThrowAsync() + .WithMessage("Download failed after 2 attempts.")).Which; + exception.InnerException.Should().BeOfType(); + handler.RangeHeaders.Should().HaveCount(2); + } + + [Fact] + public async Task DownloadFileAsync_ThrowsWhenDownloadedBytesDoNotMatchExpectedBytesAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes("abc"))); + ResumableHttpFileDownloader downloader = CreateDownloader(handler, 1); + + Func act = () => downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + 6), + null, + CancellationToken.None); + + IOException exception = (await act.Should().ThrowAsync() + .WithMessage("Download failed after 1 attempts.")).Which; + exception.InnerException.Should().BeOfType() + .Which.Message.Should().Be("Downloaded 3 bytes, but expected 6 bytes."); + } + + [Fact] + public async Task DownloadFileAsync_ThrowsWhenTransferStallsAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new BlockingStream()) + }); + ResumableHttpFileDownloader downloader = CreateDownloader( + handler, + 1, + TimeSpan.FromMilliseconds(10)); + + Func act = () => downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + null, + CancellationToken.None); + + IOException exception = (await act.Should().ThrowAsync() + .WithMessage("Download failed after 1 attempts.")).Which; + exception.InnerException.Should().BeOfType(); + } + + [Fact] + public async Task DownloadFileAsync_WhenResumeIsDisabled_ReplacesExistingFileAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "stale", TestContext.Current.CancellationToken); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes("fresh"))); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + Resume: false), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().BeNull(); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("fresh"); + } + + [Fact] + public async Task DownloadFileAsync_PartialResponseForNewFile_UsesResponseLengthAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => + { + HttpResponseMessage response = CreateResponse( + HttpStatusCode.PartialContent, + Encoding.UTF8.GetBytes("abc")); + response.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 2, 6); + return response; + }); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().BeNull(); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("abc"); + } + + [Fact] + public async Task DownloadFileAsync_ResumedResponseWithoutDeclaredTotal_CombinesExistingAndResponseBytesAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "abc", TestContext.Current.CancellationToken); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => + { + HttpResponseMessage response = CreateResponse( + HttpStatusCode.PartialContent, + Encoding.UTF8.GetBytes("def")); + response.Content.Headers.ContentRange = new ContentRangeHeaderValue(3, 5); + return response; + }); + RecordingProgress progress = new(); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + progress, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().Be("bytes=3-"); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("abcdef"); + progress.Reports.First().Should().Be(new DownloadProgress(6, 3, 50)); + progress.Reports.Last().Should().Be(new DownloadProgress(6, 6, 100)); + } + + [Fact] + public async Task DownloadFileAsync_NonRetriableFailure_DoesNotRetryAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => throw new InvalidOperationException()); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes("unexpected"))); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + Func act = () => downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + null, + CancellationToken.None); + + await act.Should().ThrowAsync(); + handler.Requests.Should().ContainSingle(); + File.Exists(destinationFilePath).Should().BeFalse(); + } + + [Fact] + public async Task DownloadFileAsync_PreCanceledToken_DoesNotSendRequestAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes("unexpected"))); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + using CancellationTokenSource cancellation = new(); + await cancellation.CancelAsync(); + + Func act = () => downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + null, + cancellation.Token); + + await act.Should().ThrowAsync(); + handler.Requests.Should().BeEmpty(); + File.Exists(destinationFilePath).Should().BeFalse(); + } + + [Fact] + public async Task DownloadFileAsync_EmptyResponse_ReportsIndeterminatePercentageAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, [])); + RecordingProgress progress = new(); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + progress, + CancellationToken.None); + + new FileInfo(destinationFilePath).Length.Should().Be(0); + progress.Reports.Should().NotBeEmpty(); + progress.Reports.Should().OnlyContain(report => + report.TotalBytes == 0 && + report.BytesDownloaded == 0 && + report.ProgressPercentage == null); + } + + /// + /// A server that refuses the resume range answers 416. The bytes already on disk are not the whole + /// file, so the transfer has to fail: reporting it complete would install truncated content as though + /// it were whole, and the launch that follows would run against it. + /// + [Fact] + public async Task DownloadFileAsync_ServerRejectsTheResumeRange_FailsAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "abc", TestContext.Current.CancellationToken); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.RequestedRangeNotSatisfiable)); + ResumableHttpFileDownloader downloader = CreateDownloader(handler, maxAttempts: 1); + + Func download = () => downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath, 6), + null, + CancellationToken.None); + + await download.Should().ThrowAsync(); + } + + private static ResumableHttpFileDownloader CreateDownloader( + QueueHttpMessageHandler handler, + int maxAttempts = 2, + TimeSpan? idleTimeout = null, + TimeSpan? progressReportInterval = null, + TimeProvider? timeProvider = null) + { + HttpClient httpClient = new(handler) + { + Timeout = Timeout.InfiniteTimeSpan + }; + + return new ResumableHttpFileDownloader( + httpClient, + null, + 4, + maxAttempts, + idleTimeout ?? TimeSpan.FromSeconds(5), + progressReportInterval ?? TimeSpan.FromMilliseconds(1), + TimeSpan.FromMilliseconds(1), + timeProvider); + } + + private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, byte[] payload) + { + return new HttpResponseMessage(statusCode) + { + Content = new ByteArrayContent(payload) + }; + } + + /// + /// Reports when the transfer first reaches for the response body, which happens only after the destination file + /// has been opened, so a paused transfer can be observed without polling. + /// + private sealed class SignalingStreamContent : StreamContent + { + private readonly TaskCompletionSource _bodyRequested = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public SignalingStreamContent(byte[] payload) + : base(new MemoryStream(payload, false)) + { + } + + public Task BodyRequested => _bodyRequested.Task; + + protected override Task CreateContentReadStreamAsync() + { + _bodyRequested.TrySetResult(); + return base.CreateContentReadStreamAsync(); + } + + protected override Task CreateContentReadStreamAsync(CancellationToken cancellationToken) + { + _bodyRequested.TrySetResult(); + return base.CreateContentReadStreamAsync(cancellationToken); + } + } + + private sealed class ChunkStream : Stream + { + private readonly Action _beforeRead; + private readonly int _chunkSize; + private readonly byte[] _payload; + private int _position; + + public ChunkStream(byte[] payload, int chunkSize, Action beforeRead) + { + _payload = payload; + _chunkSize = chunkSize; + _beforeRead = beforeRead; + } + + 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 ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + if (_position >= _payload.Length) + { + return ValueTask.FromResult(0); + } + + cancellationToken.ThrowIfCancellationRequested(); + _beforeRead(); + int bytesToCopy = Math.Min(Math.Min(_chunkSize, buffer.Length), _payload.Length - _position); + _payload.AsMemory(_position, bytesToCopy).CopyTo(buffer); + _position += bytesToCopy; + return ValueTask.FromResult(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 FailingAfterPayloadStream : Stream + { + private readonly byte[] _payload; + private int _position; + + public FailingAfterPayloadStream(byte[] payload) + { + _payload = payload; + } + + 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 ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_position >= _payload.Length) + { + throw new IOException("response interrupted"); + } + + int bytesToCopy = Math.Min(buffer.Length, _payload.Length - _position); + _payload.AsMemory(_position, bytesToCopy).CopyTo(buffer); + _position += bytesToCopy; + return ValueTask.FromResult(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 WaitingAfterPayloadStream : Stream + { + private readonly byte[] _payload; + private readonly TaskCompletionSource _waitingForCancellation = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _position; + + public WaitingAfterPayloadStream(byte[] payload) + { + _payload = payload; + } + + public Task WaitingForCancellation => _waitingForCancellation.Task; + + 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) + { + int bytesToCopy = Math.Min(buffer.Length, _payload.Length - _position); + _payload.AsMemory(_position, bytesToCopy).CopyTo(buffer); + _position += bytesToCopy; + return bytesToCopy; + } + + _waitingForCancellation.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + + 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 BlockingStream : Stream + { + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + 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) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + + 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(); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs new file mode 100644 index 00000000..45a26a5d --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs @@ -0,0 +1,64 @@ +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Models; + +public sealed class S3RequestDefaultsTests +{ + [Fact] + public void CreateManifestRequest_DefaultsToNonSslForLegacyCatalogEndpoints() + { + LauncherContentVersion version = new() + { + S3HostLink = "gen.insave.ovh:9000", + S3BucketName = "mods", + S3FolderName = "folder" + }; + + S3ObjectManifestRequest request = S3CatalogDefaults.CreateManifestRequest(version); + + request.UseSsl.Should().BeFalse(); + } + + /// + /// The keys are written out rather than read back from on purpose. Every + /// catalog entry that predates this fork resolves through these exact values, so they are a backend + /// compatibility contract, and an expectation that asked production for its own constant would follow a + /// changed key instead of reporting it. They are the legacy public credentials the original client already + /// shipped, not application secrets. + /// + [Fact] + public void CreateManifestRequest_UsesPublicCatalogKeysWhenMetadataKeysAreMissing() + { + LauncherContentVersion version = new() + { + S3HostLink = "gen.insave.ovh:9000", + S3BucketName = "mods", + S3FolderName = "folder" + }; + + S3ObjectManifestRequest request = S3CatalogDefaults.CreateManifestRequest(version); + + request.AccessKey.Should().Be("S58TYR9ISEZV8PBP8QG1"); + request.SecretKey.Should().Be("b2RU1oqVU5toJRnb4gODrXX8sBSgoLcHRX6qPWxj"); + } + + [Fact] + public void CreateManifestRequest_PreservesExplicitMetadataKeys() + { + LauncherContentVersion version = new() + { + S3HostLink = "gen.insave.ovh:9000", + S3BucketName = "mods", + S3FolderName = "folder", + S3HostPublicKey = "custom-access", + S3HostSecretKey = "custom-secret" + }; + + S3ObjectManifestRequest request = S3CatalogDefaults.CreateManifestRequest(version); + + request.AccessKey.Should().Be("custom-access"); + request.SecretKey.Should().Be("custom-secret"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/ManagedPackageSourceResolverTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/ManagedPackageSourceResolverTests.cs new file mode 100644 index 00000000..db280134 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/ManagedPackageSourceResolverTests.cs @@ -0,0 +1,264 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class ManagedPackageSourceResolverTests +{ + [Theory] + [InlineData(3_355_443_200L, 3_355_443_200L)] + [InlineData(0L, 0L)] + [InlineData(null, null)] + [InlineData(-1L, null)] + public async Task GetTotalBytesAsync_ReturnsDirectFileContentLengthAsync( + long? contentLength, + long? expectedTotalBytes) + { + Uri downloadUri = new("https://example.test/contra.zip"); + StubDownloadFileMetadataReader metadataReader = new((uri, _) => + Task.FromResult(new DownloadFileMetadata(uri, "contra.zip", contentLength))); + RecordingS3ObjectManifestReader manifestReader = new(); + ManagedPackageSourceResolver resolver = CreateResolver(metadataReader, manifestReader); + LauncherContentVersion version = TestLauncherContent.Version( + "Contra", + "009", + simpleDownloadLink: downloadUri.ToString()); + + long? totalBytes = await resolver.GetTotalBytesAsync(version, CancellationToken.None); + + totalBytes.Should().Be(expectedTotalBytes); + metadataReader.RequestCount.Should().Be(1); + manifestReader.Requests.Should().BeEmpty(); + } + + [Fact] + public async Task GetTotalBytesAsync_SumsS3ManifestIncludingEmptyManifestAsync() + { + StubDownloadFileMetadataReader metadataReader = new(); + RecordingS3ObjectManifestReader manifestReader = new(); + manifestReader.Enqueue( + new RemoteFileManifestEntry("Data/one.big", "hash", 10), + new RemoteFileManifestEntry("Data/two.big", "hash", 15)); + manifestReader.Enqueue(); + ManagedPackageSourceResolver resolver = CreateResolver(metadataReader, manifestReader); + + long? populatedSize = await resolver.GetTotalBytesAsync(CreateS3Version("1.0"), CancellationToken.None); + long? emptySize = await resolver.GetTotalBytesAsync(CreateS3Version("2.0"), CancellationToken.None); + + populatedSize.Should().Be(25); + emptySize.Should().Be(0); + metadataReader.RequestCount.Should().Be(0); + manifestReader.Requests.Should().HaveCount(2); + } + + [Fact] + public async Task ResolveAsync_AfterSizeLookup_ReusesSingleFileMetadataAsync() + { + Uri downloadUri = new("https://example.test/contra.zip"); + var metadata = new DownloadFileMetadata(downloadUri, "contra.zip", 42); + StubDownloadFileMetadataReader metadataReader = new((_, _) => Task.FromResult(metadata)); + ManagedPackageSourceResolver resolver = CreateResolver( + metadataReader, + new RecordingS3ObjectManifestReader()); + LauncherContentVersion version = TestLauncherContent.Version( + "Contra", + "009", + simpleDownloadLink: downloadUri.ToString()); + + await resolver.GetTotalBytesAsync(version, CancellationToken.None); + ManagedPackageSourceResolver.PackageSource? source = + await resolver.ResolveAsync(version, CancellationToken.None); + + source.Should().BeOfType() + .Which.Metadata.Should().BeSameAs(metadata); + metadataReader.RequestCount.Should().Be(1); + } + + [Fact] + public async Task ResolveAsync_AfterSizeLookup_ReusesS3ManifestAsync() + { + RemoteFileManifestEntry[] files = + [ + new("Data/one.big", "hash", 10) + ]; + RecordingS3ObjectManifestReader manifestReader = new(); + manifestReader.Enqueue(files); + ManagedPackageSourceResolver resolver = CreateResolver( + new StubDownloadFileMetadataReader(), + manifestReader); + LauncherContentVersion version = CreateS3Version("1.0"); + + await resolver.GetTotalBytesAsync(version, CancellationToken.None); + ManagedPackageSourceResolver.PackageSource? source = + await resolver.ResolveAsync(version, CancellationToken.None); + + source.Should().BeOfType() + .Which.Files.Should().Equal(files); + manifestReader.Requests.Should().ContainSingle(); + } + + [Fact] + public async Task GetTotalBytesAsync_ReturnsUnavailableForUnsupportedSourceAsync() + { + StubDownloadFileMetadataReader metadataReader = new(); + RecordingS3ObjectManifestReader manifestReader = new(); + ManagedPackageSourceResolver resolver = CreateResolver(metadataReader, manifestReader); + LauncherContentVersion version = TestLauncherContent.Version("Manual Mod"); + + long? totalBytes = await resolver.GetTotalBytesAsync(version, CancellationToken.None); + + totalBytes.Should().BeNull(); + metadataReader.RequestCount.Should().Be(0); + manifestReader.Requests.Should().BeEmpty(); + } + + /// + /// An unreachable provider is cached as "size unknown" so a catalog that lists many offline packages does not + /// repeat the same failing request for every card that asks again. + /// + [Fact] + public async Task GetTotalBytesAsync_MapsMetadataFailureToUnavailableAsync() + { + StubDownloadFileMetadataReader metadataReader = new((_, _) => + Task.FromException(new HttpRequestException("Offline"))); + ManagedPackageSourceResolver resolver = CreateResolver(metadataReader, new RecordingS3ObjectManifestReader()); + LauncherContentVersion version = TestLauncherContent.Version( + "Contra", + "009", + simpleDownloadLink: "https://example.test/contra.zip"); + + long? firstAttempt = await resolver.GetTotalBytesAsync(version, CancellationToken.None); + long? secondAttempt = await resolver.GetTotalBytesAsync(version, CancellationToken.None); + + firstAttempt.Should().BeNull(); + secondAttempt.Should().BeNull(); + metadataReader.RequestCount.Should().Be(1); + } + + /// + /// Cancellation is the caller's decision, not the provider's answer, so it must surface and must not be cached + /// as an unknown size that suppresses every later lookup. + /// + [Fact] + public async Task GetTotalBytesAsync_CanceledMetadataRead_LeavesSizeResolvableAsync() + { + StubDownloadFileMetadataReader metadataReader = new((uri, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new DownloadFileMetadata(uri, "contra.zip", 42)); + }); + ManagedPackageSourceResolver resolver = CreateResolver(metadataReader, new RecordingS3ObjectManifestReader()); + LauncherContentVersion version = TestLauncherContent.Version( + "Contra", + "009", + simpleDownloadLink: "https://example.test/contra.zip"); + using CancellationTokenSource cancellation = new(); + await cancellation.CancelAsync(); + + Func canceledRead = () => resolver.GetTotalBytesAsync(version, cancellation.Token); + + await canceledRead.Should().ThrowAsync(); + (await resolver.GetTotalBytesAsync(version, CancellationToken.None)).Should().Be(42); + metadataReader.RequestCount.Should().Be(1); + } + + [Fact] + public async Task GetTotalBytesAsync_CachesByContentVersionAndSourceMetadataAsync() + { + StubDownloadFileMetadataReader metadataReader = new((uri, _) => + Task.FromResult(new DownloadFileMetadata(uri, "package.zip", 42))); + ManagedPackageSourceResolver resolver = CreateResolver(metadataReader, new RecordingS3ObjectManifestReader()); + LauncherContentVersion firstVersion = TestLauncherContent.Version( + "Contra", + "1.0", + simpleDownloadLink: "https://example.test/contra.zip"); + LauncherContentVersion changedVersion = TestLauncherContent.Version( + "Contra", + "2.0", + simpleDownloadLink: "https://example.test/contra.zip"); + LauncherContentVersion relinkedVersion = TestLauncherContent.Version( + "Contra", + "2.0", + simpleDownloadLink: "https://mirror.example.test/contra.zip"); + + await resolver.GetTotalBytesAsync(firstVersion, CancellationToken.None); + await resolver.GetTotalBytesAsync(firstVersion, CancellationToken.None); + await resolver.GetTotalBytesAsync(changedVersion, CancellationToken.None); + await resolver.GetTotalBytesAsync(relinkedVersion, CancellationToken.None); + + metadataReader.RequestCount.Should().Be(3); + } + + /// + /// The same content version republished from another bucket folder is a different payload, so the cached size + /// from the previous folder must not be reused. + /// + [Fact] + public async Task GetTotalBytesAsync_ChangedS3FolderName_ResolvesTheRelocatedPackageAsync() + { + RecordingS3ObjectManifestReader manifestReader = new(); + manifestReader.Enqueue(new RemoteFileManifestEntry("Data/one.big", "hash", 10)); + manifestReader.Enqueue( + new RemoteFileManifestEntry("Data/one.big", "hash", 10), + new RemoteFileManifestEntry("Data/two.big", "hash", 15)); + ManagedPackageSourceResolver resolver = CreateResolver(new StubDownloadFileMetadataReader(), manifestReader); + LauncherContentVersion version = CreateS3Version("1.0"); + LauncherContentVersion relocatedVersion = TestLauncherContent.S3Version( + "Rise of the Reds", + "1.0", + s3FolderName: "rotr/1.0-mirror"); + + long? originalSize = await resolver.GetTotalBytesAsync(version, CancellationToken.None); + long? relocatedSize = await resolver.GetTotalBytesAsync(relocatedVersion, CancellationToken.None); + + originalSize.Should().Be(10); + relocatedSize.Should().Be(25); + manifestReader.Requests.Should().HaveCount(2); + } + + /// + /// A manifest whose declared sizes cannot add up to a real package size is reported as "size unknown". Wrapping + /// the addition around, or truncating it into a negative , would put a plausible but wrong + /// download size in front of the user. + /// + [Theory] + [InlineData(ulong.MaxValue)] + [InlineData((ulong)long.MaxValue)] + public async Task GetTotalBytesAsync_ManifestSizesExceedARealPackage_ReportsUnavailableAsync(ulong firstEntrySize) + { + RecordingS3ObjectManifestReader manifestReader = new(); + manifestReader.Enqueue( + new RemoteFileManifestEntry("Data/one.big", "hash", firstEntrySize), + new RemoteFileManifestEntry("Data/two.big", "hash", 1)); + ManagedPackageSourceResolver resolver = CreateResolver(new StubDownloadFileMetadataReader(), manifestReader); + + long? totalBytes = await resolver.GetTotalBytesAsync(CreateS3Version("1.0"), CancellationToken.None); + + totalBytes.Should().BeNull(); + } + + private static ManagedPackageSourceResolver CreateResolver( + IDownloadFileMetadataReader metadataReader, + IS3ObjectManifestReader manifestReader) + { + return new ManagedPackageSourceResolver( + metadataReader, + manifestReader, + NullLogger.Instance); + } + + private static LauncherContentVersion CreateS3Version(string version) + { + return TestLauncherContent.S3Version( + "Rise of the Reds", + version, + s3FolderName: $"rotr/{version}"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/Md5FileHashServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/Md5FileHashServiceTests.cs new file mode 100644 index 00000000..25a9fc40 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/Md5FileHashServiceTests.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Services; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class Md5FileHashServiceTests +{ + [Fact] + public async Task ComputeMd5HashAsync_ReturnsUppercaseMd5HashAsync() + { + using TestDirectory testDirectory = new(); + string filePath = Path.Combine(testDirectory.Path, "payload.txt"); + await File.WriteAllTextAsync(filePath, "abc", TestContext.Current.CancellationToken); + var service = new Md5FileHashService(); + + string hash = await service.ComputeMd5HashAsync(filePath, CancellationToken.None); + + hash.Should().Be("900150983CD24FB0D6963F7D28E17F72"); + } + + /// + /// Package integrity is decided by comparing this hash against a manifest entry, so a file that cannot be read + /// has to surface the read failure. A swallowed failure would hand the caller a hash it never computed. + /// + [Fact] + public async Task ComputeMd5HashAsync_UnreadableFile_SurfacesTheReadFailureAsync() + { + using TestDirectory testDirectory = new(); + string missingFilePath = Path.Combine(testDirectory.Path, "absent.txt"); + var service = new Md5FileHashService(); + + Func> computeHash = () => service.ComputeMd5HashAsync(missingFilePath, CancellationToken.None); + + await computeHash.Should().ThrowAsync(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadServiceTests.cs new file mode 100644 index 00000000..8bd92460 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadServiceTests.cs @@ -0,0 +1,467 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Minio.Exceptions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class PackageDownloadServiceTests +{ + private static readonly string _shockWave20 = Path.Combine("ShockWave", "2.0"); + + private static readonly string _shockWave30 = Path.Combine("ShockWave", "3.0"); + + public static TheoryData ExpectedFailureCases => + new() + { + { + new TimeoutException("signed URL expired"), + "The remote package provider could not complete the download." + }, + { + new UnexpectedMinioException("presign failed"), + "The remote package provider could not complete the download." + }, + { + new HttpRequestException("offline"), + "The remote package provider could not complete the download." + }, + { + new InvalidDataException(@"bad archive at C:\private\package.zip"), + "The downloaded package could not be validated." + }, + { + new IOException(@"install failed at C:\private\package"), + "The package could not be staged or installed in launcher storage." + }, + { + new UnauthorizedAccessException(@"denied at C:\private\package"), + "The package could not be staged or installed in launcher storage." + } + }; + + [Fact] + public async Task DownloadAsync_UsesLatestVersionLinkAndOwnedPathsForSingleFilePackageAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion oldVersion = CreateSingleFileVersion( + "1.0", + "https://example.test/old.zip"); + LauncherContentVersion latestVersion = CreateSingleFileVersion( + "2.0", + "https://www.dropbox.com/s/package/latest.zip?dl=0"); + LauncherContent modification = TestLauncherContent.From(oldVersion, latestVersion); + RecordingSingleFilePackageUpdater updater = new(); + PackageDownloadService service = CreateService(paths, updater); + PackageDownloadPauseController pauseController = new(); + + PackageDownloadResult result = await service.DownloadAsync( + modification, + latestVersion, + null, + CancellationToken.None, + pauseController); + + result.Status.Should().Be(PackageDownloadStatus.Succeeded); + (DownloadFileMetadata Metadata, PackageUpdatePathSet Paths) request = + updater.Requests.Should().ContainSingle().Which; + request.Metadata.DownloadUri.Should().Be(new Uri("https://www.dropbox.com/s/package/latest.zip?dl=1")); + request.Paths.Should().Be(TestPackageUpdatePaths.Create(paths, _shockWave20, _shockWave20)); + updater.PauseControllers.Should().ContainSingle().Which.Should().BeSameAs(pauseController); + } + + [Fact] + public async Task DownloadAsync_UsesNewGameStorageAfterRuntimeSwitchWithoutRebuildingServiceAsync() + { + using TestDirectory directory = new(); + string executableDirectory = directory.CreateDirectory("Launcher"); + var storagePaths = new LauncherStoragePaths(executableDirectory); + LauncherPaths generalsPaths = storagePaths.CreateGamePaths( + SupportedGame.Generals, + directory.CreateDirectory("GeneralsGame")); + LauncherPaths zeroHourPaths = storagePaths.CreateGamePaths( + SupportedGame.ZeroHour, + directory.CreateDirectory("ZeroHourGame")); + var runtimePaths = new LauncherRuntimePathContext(storagePaths, generalsPaths); + var updater = new RecordingSingleFilePackageUpdater(); + PackageDownloadService service = CreateService(runtimePaths, updater); + LauncherContentVersion version = TestLauncherContent.Version( + "Shared Mod", + sourceKind: ContentSourceKind.ManagedSingleFile, + simpleDownloadLink: "https://example.test/shared.zip"); + LauncherContent modification = TestLauncherContent.From(version); + + await service.DownloadAsync(modification, version, null, CancellationToken.None); + runtimePaths.SwitchActive(zeroHourPaths); + await service.DownloadAsync(modification, version, null, CancellationToken.None); + + updater.Requests.Select(request => request.Paths.InstalledPath.OwnerRoot) + .Should().Equal(generalsPaths.ModsDirectory, zeroHourPaths.ModsDirectory); + updater.Requests.Select(request => request.Paths.TemporaryPath.OwnerRoot) + .Should().Equal(generalsPaths.PackagesDirectory, zeroHourPaths.PackagesDirectory); + } + + /// + /// Reuse comes from the newest installed version, not the newest listed one, so a package with several installed + /// versions still copies from the closest predecessor rather than the oldest install. + /// + [Fact] + public async Task DownloadAsync_UsesS3ManifestAndLatestInstalledVersionPathAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContent modification = TestLauncherContent.From( + TestLauncherContent.S3Version("ShockWave", "1.0", installed: true), + TestLauncherContent.S3Version("ShockWave", "2.0", installed: true), + TestLauncherContent.S3Version("ShockWave", "3.0")); + LauncherContentVersion targetVersion = modification.Versions + .Single(version => version.Version == "3.0"); + RemoteFileManifestEntry[] manifestEntries = + [ + new("Data/file.big", StubFileHashService.MatchingHash, 10) + ]; + RecordingS3ObjectManifestReader manifestReader = new(); + manifestReader.Enqueue(manifestEntries); + RecordingS3PackageUpdater updater = new(); + PackageDownloadService service = CreateService( + paths, + s3PackageUpdater: updater, + manifestReader: manifestReader); + + PackageDownloadResult result = await service.DownloadAsync( + modification, + targetVersion, + null, + CancellationToken.None); + + result.Status.Should().Be(PackageDownloadStatus.Succeeded); + S3ObjectManifestRequest manifestRequest = manifestReader.Requests.Should().ContainSingle().Which; + manifestRequest.Endpoint.Should().Be(TestLauncherContent.S3Host); + manifestRequest.BucketName.Should().Be(TestLauncherContent.S3Bucket); + manifestRequest.Prefix.Should().Be("ShockWave/3.0"); + + S3PackageUpdateRequest updateRequest = updater.UpdateRequests.Should().ContainSingle().Which; + updateRequest.Files.Should().Equal(manifestEntries); + updateRequest.Source.Should().BeSameAs(manifestRequest); + updateRequest.PathSet.Should().Be( + TestPackageUpdatePaths.Create(paths, _shockWave30, _shockWave30, _shockWave20)); + } + + /// + /// Without an installed predecessor there is nothing to copy from, so the updater must be told so rather than + /// handed a folder that does not exist. + /// + [Fact] + public async Task DownloadAsync_NoInstalledVersion_OmitsLatestInstalledPathAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion targetVersion = TestLauncherContent.S3Version("ShockWave", "3.0"); + LauncherContent modification = TestLauncherContent.From(targetVersion); + RecordingS3PackageUpdater updater = new(); + PackageDownloadService service = CreateService(paths, s3PackageUpdater: updater); + + await service.DownloadAsync(modification, targetVersion, null, CancellationToken.None); + + updater.UpdateRequests.Should().ContainSingle() + .Which.PathSet.LatestInstalledPath.Should().BeNull(); + } + + /// + /// Only launcher-managed file kinds carry a reliable MD5 in the remote manifest; hashing anything else would + /// reject files the backend never promised a checksum for. + /// + [Fact] + public async Task DownloadAsync_RequestsHashValidationForManagedGameContentExtensionsAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion targetVersion = TestLauncherContent.S3Version("ShockWave", "3.0"); + LauncherContent modification = TestLauncherContent.From(targetVersion); + RecordingS3PackageUpdater updater = new(); + PackageDownloadService service = CreateService(paths, s3PackageUpdater: updater); + + await service.DownloadAsync(modification, targetVersion, null, CancellationToken.None); + + updater.UpdateRequests.Should().ContainSingle() + .Which.HashCheckedExtensions.Should().BeEquivalentTo( + ".w3d", + LauncherContentFileTypes.BigExtension, + ".bik", + LauncherContentFileTypes.GibExtension, + ".dds", + ".tga", + ".ini", + ".scb", + ".wnd", + ".csf", + ".str"); + } + + [Fact] + public async Task DownloadAsync_ReturnsCanceledWhenCancellationStopsPreCommitWorkAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateSingleFileVersion( + "2.0", + "https://example.test/latest.zip"); + BlockingSingleFilePackageUpdater updater = new(); + PackageDownloadService service = CreateService(paths, updater); + using CancellationTokenSource cancellation = new(); + + Task download = service.DownloadAsync( + TestLauncherContent.From(version), + version, + null, + cancellation.Token); + await updater.Started.Task.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + cancellation.Cancel(); + PackageDownloadResult result = await download.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + + result.Status.Should().Be(PackageDownloadStatus.Canceled); + } + + [Fact] + public async Task DownloadAsync_KeepsSuccessWhenCancellationArrivesAfterUpdaterCommitAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateSingleFileVersion( + "2.0", + "https://example.test/latest.zip"); + using CancellationTokenSource cancellation = new(); + RecordingSingleFilePackageUpdater updater = new() + { + Update = (_, _, _) => + { + cancellation.Cancel(); + return Task.CompletedTask; + } + }; + PackageDownloadService service = CreateService(paths, updater); + + PackageDownloadResult result = await service.DownloadAsync( + TestLauncherContent.From(version), + version, + null, + cancellation.Token); + + result.Status.Should().Be(PackageDownloadStatus.Succeeded); + } + + [Fact] + public async Task DownloadAsync_TreatsProviderFailureAfterCancellationAsCanceledAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateSingleFileVersion( + "2.0", + "https://example.test/latest.zip"); + using CancellationTokenSource cancellation = new(); + RecordingSingleFilePackageUpdater updater = new() + { + Update = (_, _, _) => + { + cancellation.Cancel(); + throw new IOException("provider aborted after cancellation"); + } + }; + PackageDownloadService service = CreateService(paths, updater); + + PackageDownloadResult result = await service.DownloadAsync( + TestLauncherContent.From(version), + version, + null, + cancellation.Token); + + result.Status.Should().Be(PackageDownloadStatus.Canceled); + } + + [Theory] + // The cases carry Exception instances, which the runner cannot serialize into + // individual rows, so discovery keeps them as one theory rather than enumerating. + [MemberData(nameof(ExpectedFailureCases), DisableDiscoveryEnumeration = true)] + public async Task DownloadAsync_ReturnsSafeRecoverableFailureForExpectedPackageFailuresAsync( + Exception failure, + string expectedMessage) + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateSingleFileVersion( + "2.0", + "https://example.test/latest.zip"); + RecordingSingleFilePackageUpdater updater = new() + { + Update = (_, _, _) => throw failure + }; + PackageDownloadService service = CreateService(paths, updater); + + PackageDownloadResult result = await service.DownloadAsync( + TestLauncherContent.From(version), + version, + null, + CancellationToken.None); + + result.Status.Should().Be(PackageDownloadStatus.RecoverableFailure); + result.Message.Should().Be(expectedMessage); + result.Message.Should().NotContain(@"C:\private"); + } + + [Fact] + public async Task DownloadAsync_ReturnsSafeUnexpectedFailureWithoutLeakingDiagnosticPathAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateSingleFileVersion( + "2.0", + "https://example.test/latest.zip"); + RecordingSingleFilePackageUpdater updater = new() + { + Update = (_, _, _) => throw new InvalidOperationException(@"failure at C:\private\package") + }; + PackageDownloadService service = CreateService(paths, updater); + + PackageDownloadResult result = await service.DownloadAsync( + TestLauncherContent.From(version), + version, + null, + CancellationToken.None); + + result.Status.Should().Be(PackageDownloadStatus.UnexpectedFailure); + result.Message.Should().Be("An unexpected package download error occurred."); + } + + [Theory] + [InlineData(ContentSourceKind.UnknownLegacy)] + [InlineData(ContentSourceKind.Manual)] + public async Task DownloadAsync_RejectsSourcesThatAreNotLauncherManagedAsync(ContentSourceKind sourceKind) + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = TestLauncherContent.Version(sourceKind: sourceKind); + RecordingSingleFilePackageUpdater singleFileUpdater = new(); + RecordingS3PackageUpdater s3Updater = new(); + RecordingS3ObjectManifestReader manifestReader = new(); + PackageDownloadService service = CreateService(paths, singleFileUpdater, s3Updater, manifestReader); + + PackageDownloadResult result = await service.DownloadAsync( + TestLauncherContent.From(version), + version, + null, + CancellationToken.None); + + result.Status.Should().Be(PackageDownloadStatus.UnexpectedFailure); + singleFileUpdater.Requests.Should().BeEmpty(); + manifestReader.Requests.Should().BeEmpty(); + s3Updater.UpdateRequests.Should().BeEmpty(); + } + + [Fact] + public async Task DownloadAsync_SerializesConcurrentProgressWithoutRegressionAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateSingleFileVersion( + "2.0", + "https://example.test/latest.zip"); + RecordingSingleFilePackageUpdater updater = new() + { + Update = async (_, progress, _) => + { + await Task.WhenAll( + Task.Run(() => progress!.Report(new PackageUpdateProgress(100, 80, 80, "a"))), + Task.Run(() => progress!.Report(new PackageUpdateProgress(100, 20, 20, "b"))), + Task.Run(() => progress!.Report(new PackageUpdateProgress(100, 120, 120, "c")))); + } + }; + PackageDownloadService service = CreateService(paths, updater); + RecordingProgress progress = new(); + + PackageDownloadResult result = await service.DownloadAsync( + TestLauncherContent.From(version), + version, + progress, + CancellationToken.None); + + result.Status.Should().Be(PackageDownloadStatus.Succeeded); + progress.Reports.Should().NotBeEmpty(); + progress.Reports.Select(report => report.BytesRead) + .Should().BeInAscendingOrder(); + progress.Reports.Select(report => report.ProgressPercentage!.Value) + .Should().BeInAscendingOrder() + .And.OnlyContain(value => value >= 0 && value <= 100); + } + + private static PackageDownloadService CreateService( + LauncherPaths paths, + ISingleFilePackageUpdater? singleFilePackageUpdater = null, + IS3PackageUpdater? s3PackageUpdater = null, + IS3ObjectManifestReader? manifestReader = null, + IDownloadFileMetadataReader? metadataReader = null) + { + return CreateService( + TestLauncherPaths.CreateRuntimePathContext(paths), + singleFilePackageUpdater, + s3PackageUpdater, + manifestReader, + metadataReader); + } + + private static PackageDownloadService CreateService( + LauncherRuntimePathContext runtimePathContext, + ISingleFilePackageUpdater? singleFilePackageUpdater = null, + IS3PackageUpdater? s3PackageUpdater = null, + IS3ObjectManifestReader? manifestReader = null, + IDownloadFileMetadataReader? metadataReader = null) + { + var packageSourceResolver = new ManagedPackageSourceResolver( + metadataReader ?? new StubDownloadFileMetadataReader("package.zip", null), + manifestReader ?? new RecordingS3ObjectManifestReader(), + NullLogger.Instance); + return new PackageDownloadService( + singleFilePackageUpdater ?? new RecordingSingleFilePackageUpdater(), + s3PackageUpdater ?? new RecordingS3PackageUpdater(), + packageSourceResolver, + runtimePathContext, + NullLogger.Instance); + } + + private static LauncherContentVersion CreateSingleFileVersion(string version, string downloadLink) + { + return TestLauncherContent.Version( + "ShockWave", + version, + sourceKind: ContentSourceKind.ManagedSingleFile, + simpleDownloadLink: downloadLink); + } + + private sealed class BlockingSingleFilePackageUpdater : ISingleFilePackageUpdater + { + public TaskCompletionSource Started { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async Task UpdateAsync( + DownloadFileMetadata metadata, + PackageUpdatePathSet paths, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + Started.TrySetResult(true); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.cs new file mode 100644 index 00000000..746085c3 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.cs @@ -0,0 +1,861 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class S3PackageUpdaterBehaviorTests +{ + [Fact] + public async Task UpdateAsync_CopiesMatchingFilesFromLatestAndSkipsDownloadAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory), "latest"); + string latestFilePath = Path.Combine(paths.LatestInstalledPath!.FullPath, "Data", "readme.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(latestFilePath)!); + await File.WriteAllTextAsync(latestFilePath, "payload", TestContext.Current.CancellationToken); + + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry( + "Data/readme.txt", + StubFileHashService.MatchingHash, + (ulong)new FileInfo(latestFilePath).Length)), + progress, + CancellationToken.None); + + downloader.Requests.Should().BeEmpty(); + (await File.ReadAllTextAsync(Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + progress.Reports.Should().Contain(report => report.FileName == null); + } + + [Theory] + [InlineData("payload", StubFileHashService.MismatchedHash)] + [InlineData("stale", StubFileHashService.MatchingHash)] + public async Task UpdateAsync_LatestFileFailingIntegrity_DownloadsReplacementAsync( + string latestContents, + string latestHash) + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory), "latest"); + string latestFilePath = Path.Combine(paths.LatestInstalledPath!.FullPath, "Data", "readme.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(latestFilePath)!); + await File.WriteAllTextAsync(latestFilePath, latestContents, TestContext.Current.CancellationToken); + + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater( + downloader, + new StubFileHashService + { + HashForPath = path => string.Equals(path, latestFilePath, StringComparison.OrdinalIgnoreCase) + ? latestHash + : StubFileHashService.MatchingHash + }); + + await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry("Data/readme.txt", StubFileHashService.MatchingHash, 7)), + null, + CancellationToken.None); + + downloader.Requests.Should().ContainSingle(); + (await File.ReadAllBytesAsync(Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Equal(CreatePayload(7)); + } + + [Fact] + public async Task UpdateAsync_ReusesInstalledGibVariantForBigManifestEntryAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory), "latest"); + string latestFilePath = Path.Combine(paths.LatestInstalledPath!.FullPath, "Data", "archive.gib"); + Directory.CreateDirectory(Path.GetDirectoryName(latestFilePath)!); + await File.WriteAllTextAsync(latestFilePath, "payload", TestContext.Current.CancellationToken); + + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + + await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry( + "Data/archive.big", + StubFileHashService.MatchingHash, + (ulong)new FileInfo(latestFilePath).Length)), + null, + CancellationToken.None); + + downloader.Requests.Should().BeEmpty(); + (await File.ReadAllTextAsync(Path.Combine(paths.InstalledPath.FullPath, "Data", "archive.gib"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + File.Exists(Path.Combine(paths.InstalledPath.FullPath, "Data", "archive.big")).Should().BeFalse(); + } + + /// + /// Files reused from an earlier version still count towards the package's size and progress, so the reported + /// total is what the user is actually installing rather than only the part that crossed the network. + /// + [Fact] + public async Task UpdateAsync_ReportsWholePackageBytesWhenLatestFilesAreReusedAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory), "latest"); + string latestFilePath = Path.Combine(paths.LatestInstalledPath!.FullPath, "Data", "reused.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(latestFilePath)!); + await File.WriteAllBytesAsync(latestFilePath, CreatePayload(660), TestContext.Current.CancellationToken); + + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry("Data/reused.txt", StubFileHashService.MatchingHash, 660), + new RemoteFileManifestEntry("Data/missing.txt", StubFileHashService.MatchingHash, 20)), + progress, + CancellationToken.None); + + downloader.Requests.Should().ContainSingle(); + progress.Reports.Should().ContainSingle(); + progress.Reports[0].TotalBytes.Should().Be(680); + progress.Reports[0].BytesRead.Should().Be(680); + progress.Reports[0].ProgressPercentage.Should().Be(100); + } + + /// + /// A resumed transfer reports against the whole package: the 5 bytes already staged count as progress rather + /// than shrinking the total, so the bar continues from where it stopped instead of restarting at zero. + /// + [Fact] + public async Task UpdateAsync_ReportsWholePackageBytesForPartialStagedDownloadAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string partialFilePath = Path.Combine(paths.TemporaryPath.FullPath, "Data", "missing.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(partialFilePath)!); + await File.WriteAllBytesAsync(partialFilePath, CreatePayload(5), TestContext.Current.CancellationToken); + + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry("Data/missing.txt", StubFileHashService.MatchingHash, 20)), + progress, + CancellationToken.None); + + 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_RejectsManifestPathOutsideTemporaryFolderAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths launcherPaths = TestLauncherPaths.Create(testDirectory); + PackageUpdatePathSet paths = CreatePackagePaths(launcherPaths); + S3PackageUpdater updater = CreateUpdater(new RecordingFileDownloader(), new StubFileHashService()); + + Func act = async () => await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry("../escape.txt", StubFileHashService.MatchingHash, 1)), + null, + CancellationToken.None); + + await act.Should().ThrowAsync(); + File.Exists(Path.Combine(launcherPaths.PackagesDirectory, "escape.txt")).Should().BeFalse(); + } + + [Fact] + public async Task UpdateAsync_PrunesStaleTemporaryFilesBeforeInstallingAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths launcherPaths = TestLauncherPaths.Create(testDirectory); + PackageUpdatePathSet paths = TestPackageUpdatePaths.Create( + launcherPaths, + Path.Combine("NProject Mod", "2.11"), + "installed"); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + await File.WriteAllTextAsync(Path.Combine(paths.TemporaryPath.FullPath, "stale.txt"), "stale", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(paths.TemporaryPath.FullPath, "readme.txt"), "payload", TestContext.Current.CancellationToken); + + S3PackageUpdater updater = CreateUpdater(new RecordingFileDownloader(), new StubFileHashService()); + + await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry("readme.txt", StubFileHashService.MatchingHash, 7)), + null, + CancellationToken.None); + + File.Exists(Path.Combine(paths.InstalledPath.FullPath, "stale.txt")).Should().BeFalse(); + (await File.ReadAllTextAsync(Path.Combine(paths.InstalledPath.FullPath, "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + Directory.Exists(launcherPaths.PackagesDirectory).Should().BeFalse(); + Directory.Exists(launcherPaths.TempDirectory).Should().BeTrue(); + } + + [Fact] + public async Task UpdateAsync_RemovesUnsafeStagingLinkWithoutDeletingTargetAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string outsidePath = testDirectory.CreateDirectory("outside"); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + await File.WriteAllTextAsync(Path.Combine(paths.TemporaryPath.FullPath, "readme.txt"), "payload", TestContext.Current.CancellationToken); + string outsideFile = Path.Combine(outsidePath, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside", TestContext.Current.CancellationToken); + ReparsePointTestSupport.CreateDirectoryJunction( + Path.Combine(paths.TemporaryPath.FullPath, "linked"), + outsidePath); + + S3PackageUpdater updater = CreateUpdater(new RecordingFileDownloader(), new StubFileHashService()); + + await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry("readme.txt", StubFileHashService.MatchingHash, 7)), + null, + CancellationToken.None); + + Directory.Exists(Path.Combine(paths.InstalledPath.FullPath, "linked")).Should().BeFalse(); + (await File.ReadAllTextAsync(outsideFile, TestContext.Current.CancellationToken)).Should().Be("outside"); + } + + /// + /// Cancellation between the transfer and the install must leave the installed folder untouched and keep the + /// staged bytes, so the next attempt resumes instead of fetching the package again. + /// + [Fact] + public async Task UpdateAsync_CancellationAfterDownload_KeepsStagedFileAndSkipsInstallAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + using CancellationTokenSource cancellation = new(); + RecordingFileDownloader downloader = new() + { + Handler = async (request, _) => + { + Directory.CreateDirectory(Path.GetDirectoryName(request.DestinationFilePath)!); + await File.WriteAllBytesAsync( + request.DestinationFilePath, + CreatePayload(5), + CancellationToken.None); + await cancellation.CancelAsync(); + } + }; + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + + Func update = () => updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry("Data/readme.txt", StubFileHashService.MatchingHash, 5)), + null, + cancellation.Token); + + await update.Should().ThrowAsync(); + Directory.Exists(paths.InstalledPath.FullPath).Should().BeFalse(); + (await File.ReadAllBytesAsync(Path.Combine(paths.TemporaryPath.FullPath, "Data", "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Equal(CreatePayload(5)); + } + + [Theory] + [InlineData("Data/readme.txt")] + [InlineData(@"Data\readme.txt")] + public async Task RepairFilesAsync_DownloadsSelectedModifiedFileInPlaceAsync(string manifestFileName) + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string staleFilePath = Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"); + string keepFilePath = Path.Combine(paths.InstalledPath.FullPath, "Data", "keep.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(staleFilePath)!); + await File.WriteAllTextAsync(staleFilePath, "stale", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(keepFilePath, "keep", TestContext.Current.CancellationToken); + + RecordingFileDownloader downloader = new(); + StubFileHashService hashService = new() + { + HashForPath = path => File.ReadAllBytes(path).All(value => value == (byte)'x') + ? StubFileHashService.MatchingHash + : StubFileHashService.MismatchedHash + }; + S3PackageUpdater updater = CreateUpdater(downloader, hashService); + RecordingProgress progress = new(); + + await updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry(manifestFileName, StubFileHashService.MatchingHash, 5)), + progress, + CancellationToken.None); + + DownloadFileRequest request = downloader.Requests.Should().ContainSingle().Which; + request.DestinationFilePath.Should().Be(staleFilePath); + request.SourceUri.AbsolutePath.Should().Be("/mods/folder/Data/readme.txt"); + (await File.ReadAllBytesAsync(staleFilePath, TestContext.Current.CancellationToken)).Should().AllBeEquivalentTo((byte)'x'); + (await File.ReadAllTextAsync(keepFilePath, TestContext.Current.CancellationToken)).Should().Be("keep"); + progress.Reports.Should().ContainSingle(report => + report.TotalBytes == 5 && + report.BytesRead == 5 && + report.ProgressPercentage == 100); + } + + /// + /// S3 publishes ETags while the launcher computes MD5 sums, and the two differ only in letter case; treating + /// that as corruption would re-download a file that is already correct. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RepairFilesAsync_HashLetterCaseDiffers_SkipsDownloadAsync(bool manifestHashIsLowercase) + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string installedFilePath = Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(installedFilePath)!); + await File.WriteAllBytesAsync(installedFilePath, CreatePayload(5), TestContext.Current.CancellationToken); + string lowercaseHash = StubFileHashService.MatchingHash.ToLowerInvariant(); + string manifestHash = manifestHashIsLowercase ? lowercaseHash : StubFileHashService.MatchingHash; + string computedHash = manifestHashIsLowercase ? StubFileHashService.MatchingHash : lowercaseHash; + + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater( + downloader, + new StubFileHashService { HashForPath = _ => computedHash }); + + await updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/readme.txt", manifestHash, 5)), + null, + CancellationToken.None); + + downloader.Requests.Should().BeEmpty(); + (await File.ReadAllBytesAsync(installedFilePath, TestContext.Current.CancellationToken)).Should().Equal(CreatePayload(5)); + } + + [Fact] + public async Task RepairFilesAsync_RejectsLinkedInstalledTreeWithoutMutatingTargetAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string outsidePath = testDirectory.CreateDirectory("outside"); + string outsideFilePath = Path.Combine(outsidePath, "readme.txt"); + await File.WriteAllTextAsync(outsideFilePath, "outside", TestContext.Current.CancellationToken); + ReparsePointTestSupport.CreateDirectoryJunction(paths.InstalledPath.FullPath, outsidePath); + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + + Func repair = () => updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("readme.txt", StubFileHashService.MatchingHash, 5)), + null, + CancellationToken.None); + + await repair.Should().ThrowAsync() + .WithMessage("*reparse point*"); + downloader.Requests.Should().BeEmpty(); + (await File.ReadAllTextAsync(outsideFilePath, TestContext.Current.CancellationToken)).Should().Be("outside"); + } + + [Fact] + public async Task RepairFilesAsync_DownloadFailureRetainsPartialFileForResumeAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string destinationPath = Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"); + RecordingFileDownloader downloader = new() + { + Handler = async (request, cancellationToken) => + { + Directory.CreateDirectory(Path.GetDirectoryName(request.DestinationFilePath)!); + await File.WriteAllBytesAsync(request.DestinationFilePath, [1, 2], cancellationToken); + throw new IOException("connection interrupted"); + } + }; + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + + Func repair = () => updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/readme.txt", StubFileHashService.MatchingHash, 5)), + null, + CancellationToken.None); + + await repair.Should().ThrowAsync() + .WithMessage("connection interrupted"); + DownloadFileRequest request = downloader.Requests.Should().ContainSingle().Which; + request.Resume.Should().BeTrue(); + (await File.ReadAllBytesAsync(destinationPath, TestContext.Current.CancellationToken)).Should().Equal([1, 2]); + } + + [Fact] + public async Task RepairFilesAsync_ResumesExistingPartialInstalledFileAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string destinationPath = Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + await File.WriteAllBytesAsync(destinationPath, [1, 2], TestContext.Current.CancellationToken); + RecordingFileDownloader downloader = new() + { + Handler = async (request, cancellationToken) => + { + request.Resume.Should().BeTrue(); + (await File.ReadAllBytesAsync(request.DestinationFilePath, cancellationToken)) + .Should().Equal([1, 2]); + await using FileStream stream = new( + request.DestinationFilePath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + 4096, + FileOptions.Asynchronous); + await stream.WriteAsync(new byte[] { 3, 4, 5 }, cancellationToken); + } + }; + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + + await updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/readme.txt", StubFileHashService.MatchingHash, 5)), + null, + CancellationToken.None); + + downloader.Requests.Should().ContainSingle(); + (await File.ReadAllBytesAsync(destinationPath, TestContext.Current.CancellationToken)).Should().Equal([1, 2, 3, 4, 5]); + } + + [Fact] + public async Task RepairFilesAsync_ExactSizeHashMismatch_RemovesStaleFileBeforeDownloadAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string destinationPath = Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + await File.WriteAllBytesAsync(destinationPath, [1, 2, 3, 4, 5], TestContext.Current.CancellationToken); + bool staleFilePresentWhenDownloadStarted = false; + RecordingFileDownloader downloader = new() + { + Handler = async (request, cancellationToken) => + { + staleFilePresentWhenDownloadStarted = File.Exists(request.DestinationFilePath); + await File.WriteAllBytesAsync( + request.DestinationFilePath, + CreatePayload(5), + cancellationToken); + } + }; + S3PackageUpdater updater = CreateUpdater( + downloader, + new StubFileHashService + { + HashForPath = path => File.ReadAllBytes(path).All(value => value == (byte)'x') + ? StubFileHashService.MatchingHash + : StubFileHashService.MismatchedHash + }); + + await updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/readme.txt", StubFileHashService.MatchingHash, 5)), + null, + CancellationToken.None); + + staleFilePresentWhenDownloadStarted.Should().BeFalse(); + downloader.Requests.Should().ContainSingle(); + (await File.ReadAllBytesAsync(destinationPath, TestContext.Current.CancellationToken)).Should().Equal(CreatePayload(5)); + } + + [Theory] + [InlineData(true, 20, 19)] + [InlineData(false, 35, 34)] + public async Task RepairFilesAsync_ResumeProgress_DistinguishesAcceptedRangeFromRestartAsync( + bool serverAcceptedResume, + long expectedTotalBytes, + long expectedIntermediateBytes) + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string destinationPath = Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + await File.WriteAllBytesAsync(destinationPath, CreatePayload(15), TestContext.Current.CancellationToken); + RecordingFileDownloader downloader = new() + { + ProgressHandler = async (request, progress, cancellationToken) => + { + progress?.Report(new DownloadProgress( + 20, + serverAcceptedResume ? 15 : 0, + serverAcceptedResume ? 75 : 0)); + if (serverAcceptedResume) + { + await using FileStream stream = new( + request.DestinationFilePath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + 4096, + FileOptions.Asynchronous); + await stream.WriteAsync(CreatePayload(5), cancellationToken); + } + else + { + await File.WriteAllBytesAsync( + request.DestinationFilePath, + CreatePayload(20), + cancellationToken); + } + + progress?.Report(new DownloadProgress(20, 20, 100)); + } + }; + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + RecordingProgress progress = new(); + + await updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/readme.txt", StubFileHashService.MatchingHash, 20)), + progress, + CancellationToken.None); + + progress.Reports.Select(report => report.TotalBytes) + .Should().OnlyContain(totalBytes => totalBytes == expectedTotalBytes); + progress.Reports.Select(report => report.BytesRead) + .Should().Equal(15, expectedIntermediateBytes, expectedTotalBytes); + progress.Reports.Take(progress.Reports.Count - 1) + .Should().OnlyContain(report => report.ProgressPercentage < 100); + progress.Reports[^1].ProgressPercentage.Should().Be(100); + } + + [Fact] + public async Task RepairFilesAsync_SizeOnlyFileMatches_SkipsDownloadAndHashAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string destinationPath = Path.Combine(paths.InstalledPath.FullPath, "Data", "asset.bin"); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + await File.WriteAllBytesAsync(destinationPath, [1, 2, 3, 4, 5], TestContext.Current.CancellationToken); + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater( + downloader, + new StubFileHashService + { + HashForPath = _ => throw new InvalidOperationException( + "Size-only package files must not be hashed.") + }); + + await updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/asset.bin", string.Empty, 5)), + null, + CancellationToken.None); + + downloader.Requests.Should().BeEmpty(); + (await File.ReadAllBytesAsync(destinationPath, TestContext.Current.CancellationToken)).Should().Equal([1, 2, 3, 4, 5]); + } + + [Fact] + public async Task RepairFilesAsync_MoreThanSixFiles_CompletesEveryDownloadAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + RemoteFileManifestEntry[] files = Enumerable.Range(0, 7) + .Select(index => new RemoteFileManifestEntry($"Data/file-{index}.bin", string.Empty, 1)) + .ToArray(); + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + using CancellationTokenSource cancellation = new(TestTimeouts.Wait); + + await updater.RepairFilesAsync( + CreateRepairRequest(paths.InstalledPath, files), + null, + cancellation.Token); + + downloader.Requests.Should().HaveCount(files.Length); + foreach (RemoteFileManifestEntry file in files) + { + File.Exists(Path.Combine(paths.InstalledPath.FullPath, file.FileName)).Should().BeTrue(); + } + } + + [Fact] + public async Task RepairFilesAsync_CancellationRetainsPartialFileForResumeAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string destinationPath = Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"); + using CancellationTokenSource cancellation = new(); + RecordingFileDownloader downloader = new() + { + Handler = async (request, cancellationToken) => + { + Directory.CreateDirectory(Path.GetDirectoryName(request.DestinationFilePath)!); + await File.WriteAllBytesAsync(request.DestinationFilePath, [1, 2], CancellationToken.None); + await cancellation.CancelAsync(); + cancellationToken.ThrowIfCancellationRequested(); + } + }; + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + + Func repair = () => updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/readme.txt", StubFileHashService.MatchingHash, 5)), + null, + cancellation.Token); + + await repair.Should().ThrowAsync(); + downloader.Requests.Should().ContainSingle().Which.Resume.Should().BeTrue(); + (await File.ReadAllBytesAsync(destinationPath, TestContext.Current.CancellationToken)).Should().Equal([1, 2]); + } + + [Fact] + public async Task RepairFilesAsync_RepeatedHashMismatchFailsAfterThreeAttemptsAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater( + downloader, + new StubFileHashService { HashForPath = _ => StubFileHashService.MismatchedHash }); + + Func repair = () => updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/readme.txt", StubFileHashService.MatchingHash, 5)), + null, + CancellationToken.None); + + await repair.Should().ThrowAsync(); + downloader.Requests.Should().HaveCount(3); + } + + [Fact] + public async Task RepairFilesAsync_BigFileHashRetry_RemovesConvertedFailureBeforeRetryAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string bigPath = Path.Combine(paths.InstalledPath.FullPath, "Data", "archive.big"); + string gibPath = Path.Combine(paths.InstalledPath.FullPath, "Data", "archive.gib"); + Directory.CreateDirectory(Path.GetDirectoryName(bigPath)!); + await File.WriteAllBytesAsync(bigPath, CreatePayload(2), TestContext.Current.CancellationToken); + int downloadAttempt = 0; + bool failedVariantsRemovedBeforeRetry = false; + RecordingFileDownloader downloader = new() + { + ProgressHandler = async (request, progress, cancellationToken) => + { + downloadAttempt++; + if (downloadAttempt == 1) + { + progress?.Report(new DownloadProgress(5, 2, 40)); + await using FileStream stream = new( + request.DestinationFilePath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + 4096, + FileOptions.Asynchronous); + await stream.WriteAsync(CreatePayload(3), cancellationToken); + } + else + { + failedVariantsRemovedBeforeRetry = + !File.Exists(bigPath) && !File.Exists(gibPath); + progress?.Report(new DownloadProgress(5, 0, 0)); + Directory.CreateDirectory(Path.GetDirectoryName(request.DestinationFilePath)!); + await File.WriteAllBytesAsync( + request.DestinationFilePath, + CreatePayload(5), + cancellationToken); + } + + progress?.Report(new DownloadProgress(5, 5, 100)); + } + }; + int hashAttempt = 0; + S3PackageUpdater updater = CreateUpdater( + downloader, + new StubFileHashService + { + HashForPath = _ => Interlocked.Increment(ref hashAttempt) == 1 + ? StubFileHashService.MismatchedHash + : StubFileHashService.MatchingHash + }); + RecordingProgress progress = new(); + + await updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/archive.big", StubFileHashService.MatchingHash, 5)), + progress, + CancellationToken.None); + + downloader.Requests.Should().HaveCount(2); + failedVariantsRemovedBeforeRetry.Should().BeTrue(); + progress.Reports[^1].TotalBytes.Should().Be(10); + progress.Reports[^1].BytesRead.Should().Be(10); + progress.Reports[^1].ProgressPercentage.Should().Be(100); + File.Exists(bigPath).Should().BeFalse(); + (await File.ReadAllBytesAsync(gibPath, TestContext.Current.CancellationToken)).Should().Equal(CreatePayload(5)); + } + + [Fact] + public async Task UpdateAsync_HashRetry_ReportsMonotonicProgressAndOnlyFinishesAtOneHundredAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + int hashAttempt = 0; + RecordingFileDownloader downloader = new() + { + ProgressHandler = async (request, progress, cancellationToken) => + { + long length = request.ExpectedBytes.GetValueOrDefault(); + Directory.CreateDirectory(Path.GetDirectoryName(request.DestinationFilePath)!); + await File.WriteAllBytesAsync( + request.DestinationFilePath, + CreatePayload(checked((int)length)), + cancellationToken); + progress?.Report(new DownloadProgress(length, length, 100)); + } + }; + S3PackageUpdater updater = CreateUpdater( + downloader, + new StubFileHashService + { + HashForPath = _ => Interlocked.Increment(ref hashAttempt) == 1 + ? StubFileHashService.MismatchedHash + : StubFileHashService.MatchingHash + }); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry("Data/file.txt", StubFileHashService.MatchingHash, 5)), + progress, + CancellationToken.None); + + progress.Reports.Should().HaveCountGreaterThan(1); + progress.Reports.Select(report => report.ProgressPercentage!.Value) + .Should().BeInAscendingOrder(); + progress.Reports.Take(progress.Reports.Count - 1) + .Should().OnlyContain(report => report.ProgressPercentage < 100); + progress.Reports[^1].ProgressPercentage.Should().Be(100); + progress.Reports[^1].BytesRead.Should().Be(progress.Reports[^1].TotalBytes); + } + + [Theory] + [InlineData("Data/file.big", "data/file.gib")] + [InlineData("Data/file.txt", @"data\file.txt")] + public async Task UpdateAsync_RejectsDuplicateNormalizedManifestDestinationsAsync( + string firstFile, + string secondFile) + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + S3PackageUpdater updater = CreateUpdater(new RecordingFileDownloader(), new StubFileHashService()); + S3PackageUpdateRequest request = CreateRequest( + paths, + new RemoteFileManifestEntry(firstFile, string.Empty, 1), + new RemoteFileManifestEntry(secondFile, string.Empty, 1)); + + Func update = () => updater.UpdateAsync( + request, + null, + CancellationToken.None); + + await update.Should().ThrowAsync() + .WithMessage("*duplicate local file destinations*"); + } + + private static S3PackageUpdater CreateUpdater( + IResumableFileDownloader downloader, + IFileHashService hashService) + { + return new S3PackageUpdater( + downloader, + hashService, + NullLogger.Instance); + } + + private static PackageUpdatePathSet CreatePackagePaths( + LauncherPaths launcherPaths, + string? latestRelativePath = null) + { + return TestPackageUpdatePaths.Create(launcherPaths, "temp", "installed", latestRelativePath); + } + + private static S3PackageUpdateRequest CreateRequest( + PackageUpdatePathSet paths, + params RemoteFileManifestEntry[] files) + { + return new S3PackageUpdateRequest( + files, + CreateSource(), + paths, + CreateHashCheckedExtensions()); + } + + private static S3PackageFileRepairRequest CreateRepairRequest( + OwnedContentPath installedPath, + params RemoteFileManifestEntry[] files) + { + return new S3PackageFileRepairRequest( + files, + CreateSource(), + installedPath, + CreateHashCheckedExtensions()); + } + + private static HashSet CreateHashCheckedExtensions() + { + return new HashSet(StringComparer.OrdinalIgnoreCase) { ".txt", ".big", ".gib" }; + } + + private static S3ObjectManifestRequest CreateSource() + { + return new S3ObjectManifestRequest( + "https://example.test", + "mods", + "folder", + "access", + "secret"); + } + + private static byte[] CreatePayload(int length) + { + byte[] payload = new byte[length]; + Array.Fill(payload, (byte)'x'); + return payload; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/SingleFilePackageUpdaterTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/SingleFilePackageUpdaterTests.cs new file mode 100644 index 00000000..e2a8fce6 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/SingleFilePackageUpdaterTests.cs @@ -0,0 +1,401 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class SingleFilePackageUpdaterTests +{ + private static readonly string _versionRelativePath = Path.Combine("NProject Mod", "2.11"); + + [Fact] + public async Task UpdateAsync_ClearsStaleTemporaryFilesBeforeInstallingAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + await File.WriteAllTextAsync(Path.Combine(paths.TemporaryPath.FullPath, "stale.txt"), "stale", TestContext.Current.CancellationToken); + SingleFilePackageUpdater updater = CreateUpdater( + CreateWritingDownloader("payload"), + CreateUnusedArchiveExtractor()); + + await updater.UpdateAsync( + CreateMetadata("readme.txt", 7), + paths, + null, + CancellationToken.None); + + File.Exists(Path.Combine(paths.InstalledPath.FullPath, "stale.txt")).Should().BeFalse(); + (await File.ReadAllTextAsync(Path.Combine(paths.InstalledPath.FullPath, "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + } + + [Fact] + public async Task UpdateAsync_RemovesEmptyPackageStagingParentsAfterInstallingAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths launcherPaths = TestLauncherPaths.Create(testDirectory); + PackageUpdatePathSet paths = TestPackageUpdatePaths.Create( + launcherPaths, + _versionRelativePath, + _versionRelativePath); + SingleFilePackageUpdater updater = CreateUpdater( + CreateWritingDownloader("payload"), + CreateUnusedArchiveExtractor()); + + await updater.UpdateAsync( + CreateMetadata("readme.txt", 7), + paths, + null, + CancellationToken.None); + + (await File.ReadAllTextAsync(Path.Combine(paths.InstalledPath.FullPath, "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + Directory.Exists(launcherPaths.PackagesDirectory).Should().BeFalse(); + Directory.Exists(launcherPaths.TempDirectory).Should().BeTrue(); + } + + [Theory] + [InlineData(".zip")] + [InlineData(".rar")] + [InlineData(".7z")] + public async Task UpdateAsync_ExtractsArchiveDeletesDownloadedArchiveAndInstallsExtractedFilesAsync( + string extension) + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + string archiveFileName = "package" + extension; + RecordingArchiveExtractor archiveExtractor = new() + { + ExtractHandler = destinationDirectory => File.WriteAllText( + Path.Combine(destinationDirectory, "extracted.gib"), + "extracted") + }; + SingleFilePackageUpdater updater = CreateUpdater( + CreateWritingDownloader("archive"), + archiveExtractor); + + await updater.UpdateAsync( + CreateMetadata(archiveFileName, 7), + paths, + null, + CancellationToken.None); + + File.Exists(Path.Combine(paths.InstalledPath.FullPath, archiveFileName)).Should().BeFalse(); + (await File.ReadAllTextAsync(Path.Combine(paths.InstalledPath.FullPath, "extracted.gib"), TestContext.Current.CancellationToken)) + .Should().Be("extracted"); + Path.GetFileName(archiveExtractor.ArchiveFilePath!).Should().Be(archiveFileName); + archiveExtractor.ConvertBigFilesToGib.Should().BeTrue(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(".")] + [InlineData("..")] + [InlineData("../escape.zip")] + [InlineData(@"nested\escape.zip")] + [InlineData("nested/escape.zip")] + [InlineData("C:escape.zip")] + [InlineData(" update = () => updater.UpdateAsync( + CreateMetadata(fileName, 7), + paths, + null, + CancellationToken.None); + + await update.Should().ThrowAsync() + .WithMessage("*safe direct file name*"); + Directory.Exists(paths.InstalledPath.FullPath).Should().BeFalse(); + File.Exists(Path.Combine(testDirectory.Path, "escape.zip")).Should().BeFalse(); + } + + [Fact] + public async Task UpdateAsync_RejectsLinkedInstalledRootWithoutDeletingTargetAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + string outsidePath = testDirectory.CreateDirectory("outside"); + string outsideFile = Path.Combine(outsidePath, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside", TestContext.Current.CancellationToken); + Directory.CreateDirectory(Path.GetDirectoryName(paths.InstalledPath.FullPath)!); + ReparsePointTestSupport.CreateDirectoryJunction(paths.InstalledPath.FullPath, outsidePath); + SingleFilePackageUpdater updater = CreateUpdater( + CreateWritingDownloader("payload"), + CreateUnusedArchiveExtractor()); + + Func update = () => updater.UpdateAsync( + CreateMetadata("readme.txt", 7), + paths, + null, + CancellationToken.None); + + await update.Should().ThrowAsync(); + (await File.ReadAllTextAsync(outsideFile, TestContext.Current.CancellationToken)).Should().Be("outside"); + } + + /// + /// Cancellation between the transfer and the install must not leave a half-installed package: nothing is moved + /// into place, and the staged bytes stay for the resumed attempt. + /// + [Fact] + public async Task UpdateAsync_CancellationAfterDownload_KeepsStagedFileAndSkipsInstallAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + using CancellationTokenSource cancellation = new(); + RecordingFileDownloader downloader = new() + { + Handler = async (request, _) => + { + await File.WriteAllTextAsync(request.DestinationFilePath, "payload", CancellationToken.None); + await cancellation.CancelAsync(); + } + }; + SingleFilePackageUpdater updater = CreateUpdater( + downloader, + CreateUnusedArchiveExtractor()); + + Func update = () => updater.UpdateAsync( + CreateMetadata("readme.txt", 7), + paths, + null, + cancellation.Token); + + await update.Should().ThrowAsync(); + Directory.Exists(paths.InstalledPath.FullPath).Should().BeFalse(); + (await File.ReadAllTextAsync(Path.Combine(paths.TemporaryPath.FullPath, "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + } + + /// + /// Package progress is reported against the remote file's declared size while the transfer runs, and the request + /// asks to resume so an interrupted attempt continues instead of refetching bytes already on disk. + /// + [Fact] + public async Task UpdateAsync_ReportsTransferProgressAgainstDeclaredPackageSizeAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + RecordingFileDownloader downloader = new() + { + ProgressHandler = async (request, progress, cancellationToken) => + { + progress?.Report(new DownloadProgress(7, 3, 42.86)); + await File.WriteAllTextAsync(request.DestinationFilePath, "payload", cancellationToken); + progress?.Report(new DownloadProgress(7, 7, 100)); + } + }; + SingleFilePackageUpdater updater = CreateUpdater( + downloader, + CreateUnusedArchiveExtractor()); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateMetadata("readme.txt", 7), + paths, + progress, + CancellationToken.None); + + progress.Reports[0].TotalBytes.Should().Be(7); + progress.Reports[0].BytesRead.Should().Be(3); + progress.Reports[^1].TotalBytes.Should().Be(7); + progress.Reports[^1].BytesRead.Should().Be(7); + progress.Reports[^1].ProgressPercentage.Should().Be(100); + DownloadFileRequest request = downloader.Requests.Should().ContainSingle().Which; + request.Resume.Should().BeTrue(); + request.ExpectedBytes.Should().Be(7); + } + + /// + /// A paused download must not clear the staging folder or start a transfer: the user paused to keep what is + /// already on disk, and resuming has to continue that attempt rather than begin a fresh one. + /// + [Fact] + public async Task UpdateAsync_PausedBeforeStart_DoesNoStagingWorkUntilResumedAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + string stagedFilePath = Path.Combine(paths.TemporaryPath.FullPath, "stale.txt"); + await File.WriteAllTextAsync(stagedFilePath, "stale", TestContext.Current.CancellationToken); + SingleFilePackageUpdater updater = CreateUpdater( + CreateWritingDownloader("payload"), + CreateUnusedArchiveExtractor()); + PackageDownloadPauseController pauseController = new(); + pauseController.Pause(); + + Task update = updater.UpdateAsync( + CreateMetadata("readme.txt", 7), + paths, + null, + CancellationToken.None, + pauseController); + bool stagingUntouchedWhilePaused = File.Exists(stagedFilePath); + pauseController.Resume(); + await update; + + stagingUntouchedWhilePaused.Should().BeTrue(); + (await File.ReadAllTextAsync(Path.Combine(paths.InstalledPath.FullPath, "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + } + + /// + /// Cancellation between extraction and installation keeps the downloaded archive, so the retry resumes from the + /// bytes already transferred instead of fetching the whole package again. + /// + [Fact] + public async Task UpdateAsync_CanceledDuringExtraction_KeepsTheDownloadedArchiveAndSkipsInstallAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + using CancellationTokenSource cancellation = new(); + RecordingArchiveExtractor archiveExtractor = new() + { + ExtractHandler = destinationDirectory => + { + File.WriteAllText(Path.Combine(destinationDirectory, "extracted.gib"), "extracted"); + cancellation.Cancel(); + } + }; + SingleFilePackageUpdater updater = CreateUpdater( + CreateWritingDownloader("archive"), + archiveExtractor); + + Func update = () => updater.UpdateAsync( + CreateMetadata("package.zip", 7), + paths, + null, + cancellation.Token); + + await update.Should().ThrowAsync(); + Directory.Exists(paths.InstalledPath.FullPath).Should().BeFalse(); + File.Exists(Path.Combine(paths.TemporaryPath.FullPath, "package.zip")).Should().BeTrue(); + } + + /// + /// A remote that declares no package size still has to publish the transfer's closing byte count, or the panel + /// is left showing a mid-transfer figure after the download has already finished. + /// + [Fact] + public async Task UpdateAsync_UndeclaredPackageSize_ReportsTheFinalTransferredByteCountAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + RecordingFileDownloader downloader = new() + { + ProgressHandler = async (request, progress, cancellationToken) => + { + await File.WriteAllTextAsync(request.DestinationFilePath, "payload", cancellationToken); + progress?.Report(new DownloadProgress(7, 3, null)); + progress?.Report(new DownloadProgress(7, 7, 100)); + } + }; + SingleFilePackageUpdater updater = CreateUpdater( + downloader, + CreateUnusedArchiveExtractor()); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateMetadata("readme.txt", null), + paths, + progress, + CancellationToken.None); + + progress.Reports[^1].BytesRead.Should().Be(7); + } + + /// + /// A transfer whose own end the downloader cannot state still moves the byte counter; only the closing report + /// depends on knowing where the transfer ends. + /// + [Fact] + public async Task UpdateAsync_TransferSizeUnknown_StillReportsTransferredBytesAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + RecordingFileDownloader downloader = new() + { + ProgressHandler = async (request, progress, cancellationToken) => + { + progress?.Report(new DownloadProgress(null, 3, null)); + await File.WriteAllTextAsync(request.DestinationFilePath, "payload", cancellationToken); + } + }; + SingleFilePackageUpdater updater = CreateUpdater( + downloader, + CreateUnusedArchiveExtractor()); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateMetadata("readme.txt", 7), + paths, + progress, + CancellationToken.None); + + progress.Reports.Should().ContainSingle().Which.BytesRead.Should().Be(3); + } + + private static SingleFilePackageUpdater CreateUpdater( + RecordingFileDownloader downloader, + RecordingArchiveExtractor archiveExtractor) + { + return new SingleFilePackageUpdater( + downloader, + archiveExtractor, + NullLogger.Instance); + } + + private static DownloadFileMetadata CreateMetadata(string fileName, long? totalBytes) + { + return new DownloadFileMetadata( + new Uri("https://example.test/" + fileName), + fileName, + totalBytes); + } + + private static PackageUpdatePathSet CreatePackagePaths(TestDirectory testDirectory) + { + return TestPackageUpdatePaths.Create( + TestLauncherPaths.Create(testDirectory), + _versionRelativePath, + _versionRelativePath); + } + + private static RecordingFileDownloader CreateWritingDownloader(string contents) + { + return new RecordingFileDownloader + { + Handler = (request, cancellationToken) => + File.WriteAllTextAsync(request.DestinationFilePath, contents, cancellationToken) + }; + } + + /// + /// Fails the test if a non-archive download is handed to the extractor. + /// + private static RecordingArchiveExtractor CreateUnusedArchiveExtractor() + { + return new RecordingArchiveExtractor + { + ExtractHandler = _ => 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..a1d4b85b --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/DownloadLinkResolverTests.cs @@ -0,0 +1,70 @@ +using System; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class DownloadLinkResolverTests +{ + /// + /// The catalog is an external contract that hands over share links with their query parameters in whatever + /// order the sharing UI produced, so each part is located by name rather than by position. + /// + [Theory] + [InlineData( + "https://www.dropbox.com/s/example/Package.7z?dl=0", + "https://www.dropbox.com/s/example/Package.7z?dl=1")] + [InlineData( + "https://onedrive.live.com/embed?cid=abc&resid=abc%211", + "https://onedrive.live.com/download?cid=abc&resid=abc%211")] + [InlineData( + "https://onedrive.live.com/?authkey=%21key&cid=896C9369E9176506&id=896C9369E9176506%21464&parId=896C9369E9176506%21463&o=OneUp", + "https://onedrive.live.com/download?cid=896C9369E9176506&resid=896C9369E9176506%21464&authkey=%21key")] + [InlineData( + "https://onedrive.live.com/?cid=896C9369E9176506&id=896C9369E9176506%21464&authkey=%21key", + "https://onedrive.live.com/download?cid=896C9369E9176506&resid=896C9369E9176506%21464&authkey=%21key")] + public void ResolveDirectDownloadLink_ConvertsSupportedShareLinks( + string link, + string expected) + { + string resolved = DownloadLinkResolver.ResolveDirectDownloadLink(link); + + resolved.Should().Be(expected); + } + + /// + /// The share links come from a catalog nobody here controls, so a OneDrive address can arrive with its query + /// parameters truncated or renamed. Each part is resolved independently and a missing one leaves its slot + /// empty: the resulting address fails as a download, which the downloader already reports, rather than + /// throwing out of link resolution and taking the whole catalog load down with it. + /// + [Theory] + [InlineData( + "https://onedrive.live.com/?o=OneUp", + "https://onedrive.live.com/download?cid=&resid=&authkey=")] + [InlineData( + "https://onedrive.live.com/?cid=896C9369E9176506&o=OneUp", + "https://onedrive.live.com/download?cid=896C9369E9176506&resid=&authkey=")] + public void ResolveDirectDownloadLink_OneDriveLinkMissingParts_LeavesThoseSlotsEmpty( + string link, + string expected) + { + string resolved = DownloadLinkResolver.ResolveDirectDownloadLink(link); + + resolved.Should().Be(expected); + } + + /// + /// A catalog entry that carries no download link is rejected as bad metadata, instead of being carried into the + /// downloader as an unusable address. + /// + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ResolveDirectDownloadLink_MissingLink_RejectsTheMetadata(string link) + { + Action resolve = () => DownloadLinkResolver.ResolveDirectDownloadLink(link); + + resolve.Should().Throw() + .WithParameterName(nameof(link)); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/MonotonicPackageProgressTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/MonotonicPackageProgressTests.cs new file mode 100644 index 00000000..4bc683f0 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/MonotonicPackageProgressTests.cs @@ -0,0 +1,181 @@ +using System; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +/// +/// Concurrent S3 file reporters publish their own view of one package transfer, so the reports arriving at the UI +/// are only usable once they are normalized into a progress value that never regresses. +/// +public sealed class MonotonicPackageProgressTests +{ + [Fact] + public void Report_LateSmallerByteCount_KeepsTheFurthestReportedPosition() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + progress.Report(new PackageUpdateProgress(500, 300, null, "first.big")); + + progress.Report(new PackageUpdateProgress(500, 120, null, "second.big")); + + inner.Reports[^1].BytesRead.Should().Be(300); + } + + [Fact] + public void Report_NegativeByteCount_ReportsNoTransferredBytes() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + + progress.Report(new PackageUpdateProgress(null, -5, null, null)); + + inner.Reports.Should().ContainSingle().Which.BytesRead.Should().Be(0); + } + + [Fact] + public void Report_ShrinkingPackageSize_KeepsTheLargestKnownPackageSize() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + progress.Report(new PackageUpdateProgress(500, 100, null, null)); + + progress.Report(new PackageUpdateProgress(200, 100, null, null)); + + inner.Reports[^1].TotalBytes.Should().Be(500); + } + + /// + /// A reporter that only knows its own byte count must not erase the package size an earlier reporter + /// established, or the bar loses its scale mid-transfer. + /// + [Fact] + public void Report_PackageSizeOmitted_KeepsThePreviouslyReportedPackageSize() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + progress.Report(new PackageUpdateProgress(200, 50, null, null)); + + progress.Report(new PackageUpdateProgress(null, 100, null, null)); + + inner.Reports[^1].TotalBytes.Should().Be(200); + } + + [Fact] + public void Report_NegativePackageSize_ReportsNoKnownPackageSize() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + + progress.Report(new PackageUpdateProgress(-500, 0, null, null)); + + inner.Reports.Should().ContainSingle().Which.TotalBytes.Should().Be(0); + } + + /// + /// A transfer that overruns its declared size is a stale size, not 400% progress, so the size grows to what has + /// actually arrived. + /// + [Fact] + public void Report_ByteCountBeyondPackageSize_GrowsThePackageSizeToMatch() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + + progress.Report(new PackageUpdateProgress(100, 400, null, null)); + + inner.Reports.Should().ContainSingle().Which.TotalBytes.Should().Be(400); + } + + [Fact] + public void Report_PercentageOmitted_DerivesItFromTheNormalizedByteCounts() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + + progress.Report(new PackageUpdateProgress(200, 50, null, null)); + + inner.Reports.Should().ContainSingle().Which.ProgressPercentage.Should().Be(25D); + } + + /// + /// An unknown package size cannot be turned into a percentage, and a bar told "0 of 0" would otherwise be asked + /// to render a division by zero. + /// + [Fact] + public void Report_EmptyPackageSize_ReportsNoPercentage() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + + progress.Report(new PackageUpdateProgress(0, 0, null, null)); + + inner.Reports.Should().ContainSingle().Which.ProgressPercentage.Should().BeNull(); + } + + /// + /// A provider that publishes its own percentage is the authority on it: an S3 package reports one aggregate + /// percentage across concurrent files, which no single byte ratio reproduces. + /// + [Fact] + public void Report_PercentageProvided_ForwardsItInsteadOfTheByteRatio() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + + progress.Report(new PackageUpdateProgress(200, 50, 90D, null)); + + inner.Reports.Should().ContainSingle().Which.ProgressPercentage.Should().Be(90D); + } + + [Fact] + public void Report_LowerPercentage_KeepsTheHighestReportedPercentage() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + progress.Report(new PackageUpdateProgress(200, 100, 50D, null)); + + progress.Report(new PackageUpdateProgress(200, 100, 10D, null)); + + inner.Reports[^1].ProgressPercentage.Should().Be(50D); + } + + [Theory] + [InlineData(5000D, 100D)] + [InlineData(-20D, 0D)] + public void Report_PercentageOutsideItsRange_ClampsItToTheBar( + double reportedPercentage, + double expectedPercentage) + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + + progress.Report(new PackageUpdateProgress(200, 0, reportedPercentage, null)); + + inner.Reports.Should().ContainSingle().Which.ProgressPercentage.Should().Be(expectedPercentage); + } + + /// + /// Only the three regression-prone values are normalized; the rest of the report is what the transfer panel + /// shows and must arrive unchanged. + /// + [Fact] + public void Report_ForwardsTheReportedFileNameSpeedAndEstimate() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + + progress.Report(new PackageUpdateProgress( + 200, + 50, + null, + "Data/english.big", + 2048D, + TimeSpan.FromSeconds(3))); + + PackageUpdateProgress report = inner.Reports.Should().ContainSingle().Which; + report.FileName.Should().Be("Data/english.big"); + report.DownloadSpeedBytesPerSecond.Should().Be(2048D); + report.EstimatedTimeRemaining.Should().Be(TimeSpan.FromSeconds(3)); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageInstallFolderReplacerTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageInstallFolderReplacerTests.cs new file mode 100644 index 00000000..606e81e8 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageInstallFolderReplacerTests.cs @@ -0,0 +1,478 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class PackageInstallFolderReplacerTests +{ + private static readonly string _versionRelativePath = Path.Combine("Mod", "1.0"); + + [Fact] + public void Replace_MovesTemporaryFolderIntoNewInstalledLocation() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "new"); + + PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + Directory.Exists(paths.TemporaryPath.FullPath).Should().BeFalse(); + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("new"); + Directory.Exists(paths.BackupPath.OwnerRoot).Should().BeFalse(); + } + + [Fact] + public void Replace_MovesExistingInstallToBackupBeforeReplacingIt() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "new"); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "old"); + + PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("new"); + Directory.Exists(paths.BackupPath.FullPath).Should().BeFalse(); + Directory.Exists(Path.GetDirectoryName(paths.BackupPath.FullPath)!).Should().BeFalse(); + Directory.Exists(paths.BackupPath.OwnerRoot).Should().BeFalse(); + } + + [Fact] + public void Replace_ThrowsWhenTemporaryFolderDoesNotExist() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw() + .WithMessage("*Temporary package folder*"); + } + + [Fact] + public void Replace_ThrowsWhenTemporaryTreeContainsReparsePoint() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + string linkTarget = testDirectory.CreateDirectory("linked-target"); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "new"); + ReparsePointTestSupport.CreateDirectoryJunction( + Path.Combine(paths.TemporaryPath.FullPath, "Linked"), + linkTarget); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw() + .WithMessage("*reparse point*"); + Directory.Exists(paths.TemporaryPath.FullPath).Should().BeTrue(); + Directory.Exists(paths.InstalledPath.FullPath).Should().BeFalse(); + } + + [Fact] + public void Replace_ThrowsWhenInstalledPathChainContainsReparsePoint() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + string realInstalledRoot = testDirectory.CreateDirectory("real-installed"); + string linkedInstalledRoot = Path.Combine(testDirectory.Path, "installed-link"); + var linkedInstalledPath = new OwnedContentPath( + testDirectory.Path, + Path.Combine(linkedInstalledRoot, _versionRelativePath)); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "new"); + ReparsePointTestSupport.CreateDirectoryJunction(linkedInstalledRoot, realInstalledRoot); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + linkedInstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw() + .WithMessage("*reparse point*"); + Directory.Exists(paths.TemporaryPath.FullPath).Should().BeTrue(); + Directory.EnumerateFileSystemEntries(realInstalledRoot).Should().BeEmpty(); + } + + [Fact] + public void Replace_RestoresExistingInstallWhenReplacementMoveFails() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "old"); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.InstalledPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw(); + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("old"); + Directory.Exists(paths.BackupPath.OwnerRoot).Should().BeFalse(); + } + + [Fact] + public void Replace_LeavesLegitimateSiblingVersionNamedBackupUntouched() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + string legitimateSibling = paths.InstalledPath.FullPath + ".backup"; + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + Directory.CreateDirectory(legitimateSibling); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "new"); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "old"); + File.WriteAllText(Path.Combine(legitimateSibling, "asset.txt"), "legitimate"); + + PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("new"); + File.ReadAllText(Path.Combine(legitimateSibling, "asset.txt")).Should().Be("legitimate"); + Directory.Exists(paths.BackupPath.FullPath).Should().BeFalse(); + } + + /// + /// A recovery backup that overlaps installed or staged content in either direction would delete the very tree it + /// exists to restore, so the overlap is rejected before anything on disk is touched. + /// + [Theory] + [InlineData("BackupInsideInstalled")] + [InlineData("InstalledInsideBackup")] + [InlineData("BackupInsideTemporary")] + [InlineData("TemporaryInsideBackup")] + public void Replace_OverlappingRecoveryPath_RejectsBeforeMutation(string overlap) + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "new"); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "old"); + string overlappingBackup = overlap switch + { + "BackupInsideInstalled" => Path.Combine(paths.InstalledPath.FullPath, "recovery"), + "InstalledInsideBackup" => Path.GetDirectoryName(paths.InstalledPath.FullPath)!, + "BackupInsideTemporary" => Path.Combine(paths.TemporaryPath.FullPath, "recovery"), + "TemporaryInsideBackup" => Path.GetDirectoryName(paths.TemporaryPath.FullPath)!, + _ => throw new ArgumentOutOfRangeException(nameof(overlap), overlap, null) + }; + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + new OwnedContentPath(testDirectory.Path, overlappingBackup), + NullLogger.Instance); + + act.Should().Throw() + .WithParameterName("backupPath"); + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("old"); + File.ReadAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt")).Should().Be("new"); + } + + [Fact] + public void Replace_KeepsCommittedInstallAndDurableRecoveryBackupWhenPostCommitCleanupFails() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "new"); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "old"); + + PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance, + _ => throw new IOException("cleanup failed")); + + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("new"); + File.ReadAllText(Path.Combine(paths.BackupPath.FullPath, "asset.txt")).Should().Be("old"); + } + + /// + /// A recovery backup that outlives its cleanup is the only copy of the previous install, so a replacement that + /// cannot remove it must stop rather than overwrite the install it can no longer restore. + /// + [Fact] + public void Replace_StaleRecoveryBackupSurvivesCleanup_FailsBeforeMutation() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + Directory.CreateDirectory(paths.BackupPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "next"); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "current"); + File.WriteAllText(Path.Combine(paths.BackupPath.FullPath, "asset.txt"), "old"); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance, + _ => { }); + + act.Should().Throw() + .WithMessage("*stale recovery backup*"); + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("current"); + File.ReadAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt")).Should().Be("next"); + } + + [Fact] + public void Replace_RestoresInterruptedBackupBeforeRejectingMissingStagingFolder() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.BackupPath.FullPath); + File.WriteAllText(Path.Combine(paths.BackupPath.FullPath, "asset.txt"), "old"); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw(); + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("old"); + Directory.Exists(paths.BackupPath.OwnerRoot).Should().BeFalse(); + } + + [Fact] + public void Replace_CommittedBackup_ReconcilesBeforeNextReplacement() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + Directory.CreateDirectory(paths.BackupPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "next"); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "current"); + File.WriteAllText(Path.Combine(paths.BackupPath.FullPath, "asset.txt"), "old"); + + PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("next"); + Directory.Exists(paths.BackupPath.FullPath).Should().BeFalse(); + } + + [Fact] + public void Replace_RejectsLinkedRecoveryBackup() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + string linkTarget = testDirectory.CreateDirectory("outside-backup"); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + File.WriteAllText(Path.Combine(linkTarget, "asset.txt"), "outside"); + Directory.CreateDirectory(Path.GetDirectoryName(paths.BackupPath.FullPath)!); + ReparsePointTestSupport.CreateDirectoryJunction(paths.BackupPath.FullPath, linkTarget); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw() + .WithMessage("*reparse point*"); + File.ReadAllText(Path.Combine(linkTarget, "asset.txt")).Should().Be("outside"); + Directory.Exists(paths.TemporaryPath.FullPath).Should().BeTrue(); + } + + /// + /// A durable recovery backup is the only copy of the previous install, so a replacement that discovers a linked + /// install folder must stop before the reconciliation step that would discard it. + /// + [Fact] + public void Replace_LinkedInstalledFolder_RejectsWithoutDiscardingTheRecoveryBackup() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + string outsideInstall = testDirectory.CreateDirectory("outside-install"); + string outsideAssetPath = testDirectory.CreateFile( + Path.Combine("outside-install", "outside.txt"), + "outside"); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.BackupPath.FullPath); + Directory.CreateDirectory(Path.GetDirectoryName(paths.InstalledPath.FullPath)!); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "next"); + File.WriteAllText(Path.Combine(paths.BackupPath.FullPath, "asset.txt"), "old"); + ReparsePointTestSupport.CreateDirectoryJunction(paths.InstalledPath.FullPath, outsideInstall); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw() + .WithMessage("*reparse point*"); + File.ReadAllText(Path.Combine(paths.BackupPath.FullPath, "asset.txt")).Should().Be("old"); + File.ReadAllText(outsideAssetPath).Should().Be("outside"); + } + + /// + /// The install folder is validated again inside the staged move, because the recovery-state cleanup that runs + /// first gives another process a window to swap the folder for a link the earlier check already cleared. + /// + [Fact] + public void Replace_InstalledFolderLinkedDuringRecoveryCleanup_RejectsBeforeMovingAnything() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + string outsideInstall = testDirectory.CreateDirectory("outside-install"); + string outsideAssetPath = testDirectory.CreateFile( + Path.Combine("outside-install", "outside.txt"), + "outside"); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + Directory.CreateDirectory(paths.BackupPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "next"); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "current"); + File.WriteAllText(Path.Combine(paths.BackupPath.FullPath, "asset.txt"), "old"); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance, + ownedBackupPath => + { + Directory.Delete(ownedBackupPath.FullPath, true); + Directory.Delete(paths.InstalledPath.FullPath, true); + ReparsePointTestSupport.CreateDirectoryJunction( + paths.InstalledPath.FullPath, + outsideInstall); + }); + + act.Should().Throw() + .WithMessage("*reparse point*"); + File.ReadAllText(outsideAssetPath).Should().Be("outside"); + File.ReadAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt")).Should().Be("next"); + } + + /// + /// Staging reached only by following a linked parent belongs to whatever the link points at, so the replacement + /// must refuse it instead of moving that content into the launcher's installed package location. + /// + [Fact] + public void Replace_TemporaryFolderBehindLinkedParent_RejectsWithoutConsumingTheLinkTarget() + { + using TestDirectory testDirectory = new(); + LauncherPaths launcherPaths = TestLauncherPaths.Create(testDirectory); + PackageUpdatePathSet paths = TestPackageUpdatePaths.Create( + launcherPaths, + _versionRelativePath, + _versionRelativePath); + string outsideStaging = testDirectory.CreateDirectory("outside-staging"); + string outsideAssetPath = testDirectory.CreateFile( + Path.Combine("outside-staging", "1.0", "asset.txt"), + "outside"); + Directory.CreateDirectory(launcherPaths.PackagesDirectory); + ReparsePointTestSupport.CreateDirectoryJunction( + Path.GetDirectoryName(paths.TemporaryPath.FullPath)!, + outsideStaging); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw() + .WithMessage("*reparse point*"); + File.ReadAllText(outsideAssetPath).Should().Be("outside"); + Directory.Exists(paths.InstalledPath.FullPath).Should().BeFalse(); + } + + /// + /// A first install has no previous version to fall back to, so it creates no recovery backup and must leave the + /// recovery root that other packages share exactly as it found it. + /// + [Fact] + public void Replace_FirstInstall_LeavesTheSharedRecoveryRootUntouched() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.BackupPath.OwnerRoot); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "new"); + + PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("new"); + Directory.Exists(paths.BackupPath.OwnerRoot).Should().BeTrue(); + } + + /// + /// Removing a committed package's stale recovery backup also prunes the recovery folders it emptied, so the + /// launcher's state directory does not accumulate one dead folder per package it has ever updated. + /// + [Fact] + public void Replace_StaleRecoveryBackupRemoved_PrunesTheEmptiedRecoveryFolders() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + Directory.CreateDirectory(paths.BackupPath.FullPath); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "current"); + File.WriteAllText(Path.Combine(paths.BackupPath.FullPath, "asset.txt"), "old"); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw(); + Directory.Exists(paths.BackupPath.OwnerRoot).Should().BeFalse(); + } + + private static PackageUpdatePathSet CreatePackagePaths(TestDirectory testDirectory) + { + return TestPackageUpdatePaths.Create( + TestLauncherPaths.Create(testDirectory), + _versionRelativePath, + _versionRelativePath); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageProgressTrackerTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageProgressTrackerTests.cs new file mode 100644 index 00000000..84a504ff --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageProgressTrackerTests.cs @@ -0,0 +1,275 @@ +using System; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class PackageProgressTrackerTests +{ + [Fact] + public void Update_ReturnsAggregateProgressForKnownTotal() + { + PackageProgressTracker tracker = new(200); + + PackageUpdateProgress? progress = tracker.Update("file-a", 50); + + progress.Should().NotBeNull(); + progress!.TotalBytes.Should().Be(200); + progress.BytesRead.Should().Be(50); + progress.ProgressPercentage.Should().Be(25); + } + + [Fact] + public void Update_DoesNotRegressWhenAnItemReportsFewerBytes() + { + PackageProgressTracker tracker = new(100); + + tracker.Update("file-a", 80); + PackageUpdateProgress? progress = tracker.Update("file-a", -5, true); + + progress.Should().NotBeNull(); + progress!.BytesRead.Should().Be(80); + progress.ProgressPercentage.Should().Be(80); + } + + [Fact] + public void Update_ClampsProgressAtOneHundredPercent() + { + PackageProgressTracker tracker = new(100); + + PackageUpdateProgress? progress = tracker.Update("file-a", 150, true); + + progress.Should().NotBeNull(); + progress!.BytesRead.Should().Be(150); + progress.ProgressPercentage.Should().Be(100); + } + + /// + /// Throttling must never swallow the terminal report: the transfer reaching the package total is published even + /// though the caller did not force it and the report interval has not elapsed. + /// + [Fact] + public void Update_ThrottlesRepeatedReportsUntilComplete() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(100, timeProvider: timeProvider); + + PackageUpdateProgress? firstProgress = tracker.Update("file-a", 10); + PackageUpdateProgress? throttledProgress = tracker.Update("file-a", 20); + PackageUpdateProgress? completedProgress = tracker.Update("file-a", 100); + + firstProgress.Should().NotBeNull(); + throttledProgress.Should().BeNull(); + completedProgress.Should().NotBeNull(); + completedProgress!.ProgressPercentage.Should().Be(100); + } + + [Fact] + public void AddExpectedBytes_IncreasesKnownTotal() + { + PackageProgressTracker tracker = new(100); + + tracker.AddExpectedBytes(50); + PackageUpdateProgress? progress = tracker.Update("file-a", 75); + + progress.Should().NotBeNull(); + progress!.TotalBytes.Should().Be(150); + progress.ProgressPercentage.Should().Be(50); + } + + [Fact] + public void AddExpectedBytes_IgnoresNonPositiveValues() + { + PackageProgressTracker tracker = new(100); + + tracker.AddExpectedBytes(0); + tracker.AddExpectedBytes(-1); + PackageUpdateProgress? progress = tracker.Update("file-a", 50); + + progress.Should().NotBeNull(); + progress!.TotalBytes.Should().Be(100); + } + + [Fact] + public void Update_ReportsSpeedAndEtaAfterEnoughElapsedTime() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(200, timeProvider: timeProvider); + + timeProvider.Advance(TimeSpan.FromMilliseconds(300)); + PackageUpdateProgress? progress = tracker.Update("file-a", 50, true); + + progress.Should().NotBeNull(); + progress!.DownloadSpeedBytesPerSecond.Should().BeGreaterThan(0); + progress.EstimatedTimeRemaining.Should().NotBeNull(); + } + + /// + /// A resumed transfer reports against the whole package, so the bar continues from where it stopped and the + /// total stays the package's real size rather than shrinking to whatever is left. + /// + [Fact] + public void ResumedBytes_CountTowardsProgressImmediately() + { + PackageProgressTracker tracker = new(1000, 400); + + PackageUpdateProgress? progress = tracker.Update("file-a", 100, true); + + progress.Should().NotBeNull(); + progress!.TotalBytes.Should().Be(1000); + progress.BytesRead.Should().Be(500); + progress.ProgressPercentage.Should().Be(50); + } + + /// + /// Bytes that were already on disk were not moved by this session, so crediting them to the transfer rate + /// would report a speed and an estimate the connection is not actually achieving. + /// + [Fact] + public void ReportedTransferRate_ResumedBytes_ExcludesPreviouslyDownloadedBytes() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(1000, 400, timeProvider); + + timeProvider.Advance(TimeSpan.FromSeconds(2)); + PackageUpdateProgress? progress = tracker.Update("file-a", 100, true); + + progress.Should().NotBeNull(); + progress!.DownloadSpeedBytesPerSecond.Should().Be(50); + } + + /// + /// Throttling is measured from the previous published report, so the first update after the interval has passed + /// reaches the UI instead of waiting for the one after it. + /// + [Fact] + public void Update_OnTheReportInterval_PublishesTheReport() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(1000, timeProvider: timeProvider); + tracker.Update("file-a", 100); + + timeProvider.Advance(TimeSpan.FromMilliseconds(100)); + PackageUpdateProgress? progress = tracker.Update("file-a", 200); + + progress.Should().NotBeNull(); + } + + /// + /// A transfer that has been running for a while still throttles: the interval is measured from the last report, + /// not from the start, so a chatty downloader cannot flood the UI later in the transfer. + /// + [Fact] + public void Update_WithinTheReportIntervalOfTheLastReport_PublishesNothing() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(1000, timeProvider: timeProvider); + timeProvider.Advance(TimeSpan.FromMilliseconds(200)); + tracker.Update("file-a", 100); + + timeProvider.Advance(TimeSpan.FromMilliseconds(50)); + PackageUpdateProgress? progress = tracker.Update("file-a", 150); + + progress.Should().BeNull(); + } + + /// + /// An empty manifest gives a package size of zero, which has no percentage to show; dividing by it would put a + /// on the progress bar. + /// + [Fact] + public void Update_EmptyPackage_ReportsNoPercentage() + { + PackageProgressTracker tracker = new(0); + + PackageUpdateProgress? progress = tracker.Update("file-a", 0, true); + + progress.Should().NotBeNull(); + progress!.ProgressPercentage.Should().BeNull(); + } + + /// + /// A failed hash costs a retry that enlarges the expected total, so the same transferred bytes are suddenly a + /// smaller share of the package. The bar holds its position rather than jumping backwards. + /// + [Fact] + public void Update_ExpectedTotalGrowsAfterAReport_HoldsTheReportedPercentage() + { + PackageProgressTracker tracker = new(100); + tracker.Update("file-a", 80, true); + tracker.AddExpectedBytes(100); + + PackageUpdateProgress? progress = tracker.Update("file-a", 80, true); + + progress.Should().NotBeNull(); + progress!.ProgressPercentage.Should().Be(80); + } + + /// + /// A rate measured over a quarter second or less is noise rather than a transfer speed, so none is reported + /// until the sampling window has actually passed. + /// + [Fact] + public void Update_OnTheRateSamplingWindow_ReportsNoTransferRate() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(1000, timeProvider: timeProvider); + + timeProvider.Advance(TimeSpan.FromMilliseconds(250)); + PackageUpdateProgress? progress = tracker.Update("file-a", 100, true); + + progress.Should().NotBeNull(); + progress!.DownloadSpeedBytesPerSecond.Should().BeNull(); + } + + /// + /// A resumed transfer that has not yet received a new byte has no measured rate at all, which is not the same + /// claim as a measured rate of zero. + /// + [Fact] + public void Update_NoBytesTransferredSinceResuming_ReportsNoTransferRate() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(1000, 400, timeProvider); + + timeProvider.Advance(TimeSpan.FromSeconds(1)); + PackageUpdateProgress? progress = tracker.Update("file-a", 0, true); + + progress.Should().NotBeNull(); + progress!.DownloadSpeedBytesPerSecond.Should().BeNull(); + } + + /// + /// A package whose size the provider never declared still has a measurable transfer rate, but nothing to + /// subtract it from, so no time estimate can be offered. + /// + [Fact] + public void Update_UnknownPackageSize_ReportsATransferRateButNoEstimate() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(null, timeProvider: timeProvider); + + timeProvider.Advance(TimeSpan.FromSeconds(2)); + PackageUpdateProgress? progress = tracker.Update("file-a", 500, true); + + progress.Should().NotBeNull(); + progress!.DownloadSpeedBytesPerSecond.Should().Be(250); + progress.EstimatedTimeRemaining.Should().BeNull(); + } + + /// + /// The estimate the panel counts down is the bytes still outstanding divided by the rate measured so far. + /// + [Fact] + public void Update_EstimatesTheTimeRemainingFromTheOutstandingBytesAndMeasuredRate() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(1000, timeProvider: timeProvider); + + timeProvider.Advance(TimeSpan.FromSeconds(2)); + PackageUpdateProgress? progress = tracker.Update("file-a", 500, true); + + progress.Should().NotBeNull(); + progress!.EstimatedTimeRemaining.Should().Be(TimeSpan.FromSeconds(2)); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageStagingFolderCleanerTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageStagingFolderCleanerTests.cs new file mode 100644 index 00000000..1648f7a4 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageStagingFolderCleanerTests.cs @@ -0,0 +1,196 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class PackageStagingFolderCleanerTests +{ + [Fact] + public void ClearDirectory_CreatesStagingFolderAndDeletesExistingChildren() + { + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + string childFolder = Path.Combine(stagingFolder, "Data"); + Directory.CreateDirectory(childFolder); + File.WriteAllText(Path.Combine(stagingFolder, "stale.txt"), "stale"); + File.WriteAllText(Path.Combine(childFolder, "nested.txt"), "nested"); + + PackageStagingFolderCleaner.ClearDirectory( + OwnPackagePath(testDirectory.Path, stagingFolder), + NullLogger.Instance); + + Directory.Exists(stagingFolder).Should().BeTrue(); + Directory.EnumerateFileSystemEntries(stagingFolder).Should().BeEmpty(); + } + + [Fact] + public void DeleteEmptyPackageParents_RemovesEmptyChainThroughPackagesFolder() + { + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + string packageFolder = Path.GetDirectoryName(stagingFolder)!; + string packagesFolder = Path.GetDirectoryName(packageFolder)!; + Directory.CreateDirectory(packageFolder); + + PackageStagingFolderCleaner.DeleteEmptyPackageParents( + OwnPackagePath(testDirectory.Path, stagingFolder), + NullLogger.Instance); + + Directory.Exists(packageFolder).Should().BeFalse(); + Directory.Exists(packagesFolder).Should().BeFalse(); + Directory.Exists(testDirectory.Path).Should().BeTrue(); + } + + [Fact] + public void DeleteEmptyPackageParents_StopsWhenParentContainsOtherEntries() + { + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + string packageFolder = Path.GetDirectoryName(stagingFolder)!; + string packagesFolder = Path.GetDirectoryName(packageFolder)!; + Directory.CreateDirectory(packageFolder); + File.WriteAllText(Path.Combine(packagesFolder, "keep.txt"), "keep"); + + PackageStagingFolderCleaner.DeleteEmptyPackageParents( + OwnPackagePath(testDirectory.Path, stagingFolder), + NullLogger.Instance); + + Directory.Exists(packageFolder).Should().BeFalse(); + Directory.Exists(packagesFolder).Should().BeTrue(); + File.Exists(Path.Combine(packagesFolder, "keep.txt")).Should().BeTrue(); + } + + [Fact] + public void DeleteEmptyPackageParents_ReturnsWhenPathIsNotUnderPackagesFolder() + { + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Mod", "1.0"); + string packageFolder = Path.GetDirectoryName(stagingFolder)!; + Directory.CreateDirectory(packageFolder); + + PackageStagingFolderCleaner.DeleteEmptyPackageParents( + new OwnedContentPath(testDirectory.Path, stagingFolder), + NullLogger.Instance); + + Directory.Exists(packageFolder).Should().BeTrue(); + } + + [Fact] + public void DeleteEmptyPackageParents_ReturnsWhenPackagesAncestorDoesNotExist() + { + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + + Action act = () => PackageStagingFolderCleaner.DeleteEmptyPackageParents( + OwnPackagePath(testDirectory.Path, stagingFolder), + NullLogger.Instance); + + act.Should().NotThrow(); + Directory.Exists(Path.Combine(testDirectory.Path, "Packages")).Should().BeFalse(); + } + + [Fact] + public void RemoveUnsafeLinks_HonorsPreCanceledToken() + { + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + Directory.CreateDirectory(stagingFolder); + File.WriteAllText(Path.Combine(stagingFolder, "file.txt"), "file"); + using CancellationTokenSource cancellationTokenSource = new(); + cancellationTokenSource.Cancel(); + + Action act = () => PackageStagingFolderCleaner.RemoveUnsafeLinks( + OwnPackagePath(testDirectory.Path, stagingFolder), + NullLogger.Instance, + cancellationTokenSource.Token); + + act.Should().Throw(); + } + + [Fact] + public void RemoveUnsafeLinks_RecursesThroughOrdinaryDirectories() + { + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + string nestedFolder = Path.Combine(stagingFolder, "Data"); + Directory.CreateDirectory(nestedFolder); + string filePath = Path.Combine(nestedFolder, "file.txt"); + File.WriteAllText(filePath, "file"); + + PackageStagingFolderCleaner.RemoveUnsafeLinks( + OwnPackagePath(testDirectory.Path, stagingFolder), + NullLogger.Instance, + CancellationToken.None); + + File.Exists(filePath).Should().BeTrue(); + } + + [Fact] + public void PruneToManifest_DeletesStaleFilesAndKeepsConvertedBigFiles() + { + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + string nestedFolder = Path.Combine(stagingFolder, "Data"); + string emptyFolder = Path.Combine(stagingFolder, "Empty"); + Directory.CreateDirectory(nestedFolder); + Directory.CreateDirectory(Path.Combine(emptyFolder, "Inner", "Deepest")); + File.WriteAllText(Path.Combine(stagingFolder, "keep.txt"), "keep"); + File.WriteAllText(Path.Combine(stagingFolder, "stale.txt"), "stale"); + File.WriteAllText(Path.Combine(nestedFolder, "asset.gib"), "asset"); + File.WriteAllText(Path.Combine(nestedFolder, "old.txt"), "old"); + RemoteFileManifestEntry[] files = + [ + new("keep.txt", "hash", 4), + new("Data/asset.big", "hash", 5) + ]; + + PackageStagingFolderCleaner.PruneToManifest( + OwnPackagePath(testDirectory.Path, stagingFolder), + files, + NullLogger.Instance, + CancellationToken.None); + + File.Exists(Path.Combine(stagingFolder, "keep.txt")).Should().BeTrue(); + File.Exists(Path.Combine(nestedFolder, "asset.gib")).Should().BeTrue(); + File.Exists(Path.Combine(stagingFolder, "stale.txt")).Should().BeFalse(); + File.Exists(Path.Combine(nestedFolder, "old.txt")).Should().BeFalse(); + Directory.Exists(emptyFolder).Should().BeFalse(); + Directory.EnumerateFiles(stagingFolder, "*", SearchOption.AllDirectories) + .Select(Path.GetFileName) + .Should() + .BeEquivalentTo("keep.txt", "asset.gib"); + } + + [Fact] + public void PruneToManifest_HonorsPreCanceledToken() + { + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + Directory.CreateDirectory(stagingFolder); + File.WriteAllText(Path.Combine(stagingFolder, "keep.txt"), "keep"); + File.WriteAllText(Path.Combine(stagingFolder, "stale.txt"), "stale"); + using CancellationTokenSource cancellationTokenSource = new(); + cancellationTokenSource.Cancel(); + + Action act = () => PackageStagingFolderCleaner.PruneToManifest( + OwnPackagePath(testDirectory.Path, stagingFolder), + [new RemoteFileManifestEntry("keep.txt", "hash", 4)], + NullLogger.Instance, + cancellationTokenSource.Token); + + act.Should().Throw(); + File.Exists(Path.Combine(stagingFolder, "keep.txt")).Should().BeTrue(); + File.Exists(Path.Combine(stagingFolder, "stale.txt")).Should().BeTrue(); + } + + private static OwnedContentPath OwnPackagePath(string root, string fullPath) + { + return new OwnedContentPath(Path.Combine(root, "Packages"), fullPath); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/S3HashValidationPolicyTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/S3HashValidationPolicyTests.cs new file mode 100644 index 00000000..41b8ef5c --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/S3HashValidationPolicyTests.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class S3HashValidationPolicyTests +{ + [Theory] + [InlineData("0123456789abcdef0123456789abcdef")] + [InlineData("0123456789ABCDEF0123456789ABCDEF")] + [InlineData("0123456789abcdef0123456789ABCDEF")] + public void IsReliableMd5Hash_ReturnsTrueForPlainHexMd5(string hash) + { + bool result = S3HashValidationPolicy.IsReliableMd5Hash(hash); + + result.Should().BeTrue(); + } + + [Theory] + [InlineData("")] + [InlineData("0123456789abcdef0123456789abcde")] + [InlineData("0123456789abcdef0123456789abcdef-2")] + [InlineData("0123456789abcdef0123456789abcdeg")] + public void IsReliableMd5Hash_ReturnsFalseForMultipartOrMalformedHashes(string hash) + { + bool result = S3HashValidationPolicy.IsReliableMd5Hash(hash); + + result.Should().BeFalse(); + } + + [Theory] + [InlineData("Data/asset.big", "0123456789abcdef0123456789abcdef", true)] + [InlineData("Data/readme.txt", "0123456789abcdef0123456789abcdef", false)] + [InlineData("Data/asset.big", "0123456789abcdef0123456789abcdef-2", false)] + public void ShouldCheckHash_ReturnsExpectedResult( + string path, + string hash, + bool expected) + { + RemoteFileManifestEntry file = new(path, hash, 10); + HashSet hashCheckedExtensions = new( + new[] { ".big" }, + StringComparer.OrdinalIgnoreCase); + + bool result = S3HashValidationPolicy.ShouldCheckHash(file, hashCheckedExtensions); + + result.Should().Be(expected); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/S3ReusablePackageFileCopierTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/S3ReusablePackageFileCopierTests.cs new file mode 100644 index 00000000..7aa582fd --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/S3ReusablePackageFileCopierTests.cs @@ -0,0 +1,374 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class S3ReusablePackageFileCopierTests +{ + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task CopyUnchangedFilesAsync_RejectsLinkedSourceOrDestinationTreeAsync( + bool sourceIsUnsafe, + bool linkedOwner) + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + string unsafeOwner = sourceIsUnsafe ? sourceOwner : destinationOwner; + string unsafePath = sourceIsUnsafe ? sourcePath : destinationPath; + + if (linkedOwner) + { + Directory.Delete(unsafeOwner, true); + string ownerTarget = testDirectory.CreateDirectory("LinkedOwnerTarget"); + Directory.CreateDirectory(Path.Combine(ownerTarget, Path.GetFileName(unsafePath))); + ReparsePointTestSupport.CreateDirectoryJunction(unsafeOwner, ownerTarget); + } + else + { + string linkTarget = testDirectory.CreateDirectory("LinkedChildTarget"); + ReparsePointTestSupport.CreateDirectoryJunction(Path.Combine(unsafePath, "Linked"), linkTarget); + } + + S3ReusablePackageFileCopier copier = CreateReusableFileCopier(new StubFileHashService()); + string expectedMessage = sourceIsUnsafe + ? "Reusable package paths must not contain reparse points." + : "Package staging paths must not contain reparse points."; + + Func copy = () => copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + Array.Empty(), + CancellationToken.None); + + await copy.Should().ThrowAsync() + .WithMessage(expectedMessage); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CopyUnchangedFilesAsync_CancellationBeforeFileOrDirectoryWork_StopsAsync( + bool sourceContainsFile) + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + if (sourceContainsFile) + { + testDirectory.CreateFile("SourceOwner/Latest/unmatched.txt", "unmatched"); + } + else + { + testDirectory.CreateDirectory("SourceOwner/Latest/Nested"); + } + + using CancellationTokenSource cancellationSource = new(); + await cancellationSource.CancelAsync(); + S3ReusablePackageFileCopier copier = CreateReusableFileCopier(new StubFileHashService()); + + Func copy = () => copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + Array.Empty(), + cancellationSource.Token); + + await copy.Should().ThrowAsync(); + } + + [Theory] + [InlineData(false, "Reusable package paths must not contain reparse points.")] + [InlineData(true, "Package staging paths must not contain reparse points.")] + public async Task CopyUnchangedFilesAsync_RejectsLinkIntroducedAfterHashBeforeCopyOrTraversalAsync( + bool linkDestination, + string expectedMessage) + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + string sourceNestedPath = testDirectory.CreateDirectory("SourceOwner/Latest/Nested"); + string triggerPath = testDirectory.CreateFile("SourceOwner/Latest/trigger.txt", "payload"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + string outsidePath = testDirectory.CreateDirectory("Outside"); + StubFileHashService hashService = new() + { + HashForPath = path => + { + if (string.Equals(path, triggerPath, StringComparison.OrdinalIgnoreCase)) + { + string linkPath = sourceNestedPath; + if (linkDestination) + { + Directory.Delete(destinationPath, false); + linkPath = destinationPath; + } + else + { + Directory.Delete(sourceNestedPath, false); + } + + ReparsePointTestSupport.CreateDirectoryJunction(linkPath, outsidePath); + } + + return StubFileHashService.MatchingHash; + } + }; + S3ReusablePackageFileCopier copier = CreateReusableFileCopier(hashService); + + Func copy = () => copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + [new RemoteFileManifestEntry("trigger.txt", StubFileHashService.MatchingHash, 7)], + CancellationToken.None); + + await copy.Should().ThrowAsync() + .WithMessage(expectedMessage); + Directory.EnumerateFileSystemEntries(outsidePath).Should().BeEmpty(); + } + + [SymbolicLinkFact] + public async Task CopyUnchangedFilesAsync_FileReplacementDuringHash_FailsClosedAsync() + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + string sourceFilePath = testDirectory.CreateFile("SourceOwner/Latest/payload.txt", "payload"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + string outsideFilePath = testDirectory.CreateFile("Outside/payload.txt", "outside"); + StubFileHashService hashService = new() + { + HashForPath = path => + { + File.Delete(path); + SymbolicLinkTestSupport.CreateFileLink(path, outsideFilePath); + return StubFileHashService.MatchingHash; + } + }; + S3ReusablePackageFileCopier copier = CreateReusableFileCopier(hashService); + + Func copy = () => copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + [new RemoteFileManifestEntry("payload.txt", StubFileHashService.MatchingHash, 7)], + CancellationToken.None); + + await copy.Should().ThrowAsync(); + (await File.ReadAllTextAsync(sourceFilePath)).Should().Be("payload"); + File.Exists(Path.Combine(destinationPath, "payload.txt")).Should().BeFalse(); + (await File.ReadAllTextAsync(outsideFilePath)).Should().Be("outside"); + } + + [Fact] + public async Task CopyUnchangedFilesAsync_SameLengthRewriteDuringHash_FailsClosedAsync() + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + string sourceFilePath = testDirectory.CreateFile("SourceOwner/Latest/payload.txt", "payload"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + StubFileHashService hashService = new() + { + HashForPath = path => + { + File.WriteAllText(path, "changed"); + return StubFileHashService.MatchingHash; + } + }; + S3ReusablePackageFileCopier copier = CreateReusableFileCopier(hashService); + + Func copy = () => copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + [new RemoteFileManifestEntry("payload.txt", StubFileHashService.MatchingHash, 7)], + CancellationToken.None); + + await copy.Should().ThrowAsync(); + (await File.ReadAllTextAsync(sourceFilePath, TestContext.Current.CancellationToken)).Should().Be("payload"); + File.Exists(Path.Combine(destinationPath, "payload.txt")).Should().BeFalse(); + } + + [Fact] + public async Task CopyUnchangedFilesAsync_UsesRecursiveManifestPathAndSkipsMissingIndexEntryAsync() + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + testDirectory.CreateFile("SourceOwner/Latest/readme.txt", "payload"); + testDirectory.CreateFile("SourceOwner/Latest/Nested/readme.txt", "payload"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + S3ReusablePackageFileCopier copier = CreateReusableFileCopier( + new StubFileHashService { HashForPath = _ => StubFileHashService.MatchingHash }); + + await copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + [new RemoteFileManifestEntry("Nested/readme.txt", StubFileHashService.MatchingHash, 7)], + CancellationToken.None); + + File.Exists(Path.Combine(destinationPath, "readme.txt")).Should().BeFalse(); + (await File.ReadAllTextAsync(Path.Combine(destinationPath, "Nested", "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + } + + /// + /// An S3 ETag and a locally computed MD5 sum describe the same bytes in different letter case, so a case + /// difference must not cost the user a re-download of a file that is already correct. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CopyUnchangedFilesAsync_HashLetterCaseDiffers_ReusesFileAsync(bool manifestHashIsLowercase) + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + testDirectory.CreateFile("SourceOwner/Latest/payload.txt", "payload"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + string lowercaseHash = StubFileHashService.MatchingHash.ToLowerInvariant(); + string manifestHash = manifestHashIsLowercase ? lowercaseHash : StubFileHashService.MatchingHash; + string computedHash = manifestHashIsLowercase ? StubFileHashService.MatchingHash : lowercaseHash; + S3ReusablePackageFileCopier copier = CreateReusableFileCopier( + new StubFileHashService { HashForPath = _ => computedHash }); + + await copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + [new RemoteFileManifestEntry("payload.txt", manifestHash, 7)], + CancellationToken.None); + + (await File.ReadAllTextAsync(Path.Combine(destinationPath, "payload.txt"), TestContext.Current.CancellationToken)).Should().Be("payload"); + } + + /// + /// Bytes already staged by an interrupted transfer are the authority for that file: reuse leaves them alone + /// instead of failing on the existing destination or paying to hash a copy it will not write. + /// + [Fact] + public async Task CopyUnchangedFilesAsync_DestinationAlreadyStaged_KeepsStagedFileAsync() + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + testDirectory.CreateFile("SourceOwner/Latest/Nested/readme.txt", "payload"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + string stagedFilePath = testDirectory.CreateFile("DestinationOwner/Staging/Nested/readme.txt", "staged!"); + List hashedPaths = []; + S3ReusablePackageFileCopier copier = CreateReusableFileCopier( + new StubFileHashService + { + HashForPath = path => + { + hashedPaths.Add(path); + return StubFileHashService.MatchingHash; + } + }); + + Func copy = () => copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + [new RemoteFileManifestEntry("Nested/readme.txt", StubFileHashService.MatchingHash, 7)], + CancellationToken.None); + + await copy.Should().NotThrowAsync(); + (await File.ReadAllTextAsync(stagedFilePath, TestContext.Current.CancellationToken)).Should().Be("staged!"); + hashedPaths.Should().BeEmpty(); + } + + [Theory] + [InlineData(8, StubFileHashService.MatchingHash, StubFileHashService.MatchingHash)] + [InlineData(7, "unreliable", "unreliable")] + [InlineData(7, StubFileHashService.MatchingHash, StubFileHashService.MismatchedHash)] + public async Task CopyUnchangedFilesAsync_IntegrityFailure_DoesNotReuseAsync( + int manifestSize, + string manifestHash, + string computedHash) + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + testDirectory.CreateFile("SourceOwner/Latest/payload.txt", "payload"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + S3ReusablePackageFileCopier copier = CreateReusableFileCopier( + new StubFileHashService { HashForPath = _ => computedHash }); + + await copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + [new RemoteFileManifestEntry("payload.txt", manifestHash, (ulong)manifestSize)], + CancellationToken.None); + + File.Exists(Path.Combine(destinationPath, "payload.txt")).Should().BeFalse(); + } + + /// + /// Each staging subfolder is re-validated as the walk descends into it, because the files copied at the level + /// above give another process a window to swap that subfolder for a link before anything is written into it. + /// + [Fact] + public async Task CopyUnchangedFilesAsync_StagingSubfolderLinkedDuringCopy_RejectsBeforeDescendingAsync() + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + string triggerPath = testDirectory.CreateFile("SourceOwner/Latest/trigger.txt", "payload"); + testDirectory.CreateFile("SourceOwner/Latest/Nested/Deeper/readme.txt", "payload"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + string outsidePath = testDirectory.CreateDirectory("Outside"); + StubFileHashService hashService = new() + { + HashForPath = path => + { + if (string.Equals(path, triggerPath, StringComparison.OrdinalIgnoreCase)) + { + ReparsePointTestSupport.CreateDirectoryJunction( + Path.Combine(destinationPath, "Nested"), + outsidePath); + } + + return StubFileHashService.MatchingHash; + } + }; + S3ReusablePackageFileCopier copier = CreateReusableFileCopier(hashService); + + Func copy = () => copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + [ + new RemoteFileManifestEntry("trigger.txt", StubFileHashService.MatchingHash, 7), + new RemoteFileManifestEntry("Nested/Deeper/readme.txt", StubFileHashService.MatchingHash, 7) + ], + CancellationToken.None); + + await copy.Should().ThrowAsync() + .WithMessage("Package staging paths must not contain reparse points."); + Directory.EnumerateFileSystemEntries(outsidePath).Should().BeEmpty(); + } + + private static S3ReusablePackageFileCopier CreateReusableFileCopier(IFileHashService hashService) + { + return new S3ReusablePackageFileCopier(hashService, NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/Properties/AssemblyInfo.cs b/GenLauncherGO.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..220f5287 --- /dev/null +++ b/GenLauncherGO.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Runtime.InteropServices; + +// Matches the production assemblies: the test P/Invokes target kernel32 only, so +// resolution is restricted to System32 rather than searching the test output +// directory first. +[assembly: DefaultDllImportSearchPaths(DllImportSearchPath.System32)] diff --git a/GenLauncherGO.Tests/Testing/ApplicationThemeScope.cs b/GenLauncherGO.Tests/Testing/ApplicationThemeScope.cs new file mode 100644 index 00000000..df3447b0 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/ApplicationThemeScope.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Restores the application-scoped launcher theme resources a test replaced. +/// +/// +/// Assigning LauncherRuntimeContext.Colors publishes into Application.Current.Resources, which the +/// headless session shares across every test in the Avalonia collection. Without this, a test that previews a +/// palette leaves it behind for whatever runs next. +/// +internal sealed class ApplicationThemeScope : IDisposable +{ + private static readonly string[] _themedKeyPrefixes = ["GenLauncher", "ListBox", "Dialog"]; + + private readonly Dictionary _previousValues = []; + + private readonly IResourceDictionary? _resources; + + public ApplicationThemeScope() + { + _resources = Application.Current?.Resources; + if (_resources is null) + { + return; + } + + foreach (object key in ThemedKeys(_resources)) + { + _previousValues[key] = _resources[key]; + } + } + + public void Dispose() + { + if (_resources is null) + { + return; + } + + foreach (object key in ThemedKeys(_resources)) + { + if (!_previousValues.ContainsKey(key)) + { + _resources.Remove(key); + } + } + + foreach ((object key, object? value) in _previousValues) + { + _resources[key] = value; + } + } + + private static List ThemedKeys(IResourceDictionary resources) + { + return resources.Keys + .Where(key => key is string name && + _themedKeyPrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.Ordinal))) + .ToList(); + } +} diff --git a/GenLauncherGO.Tests/Testing/AvaloniaCollection.cs b/GenLauncherGO.Tests/Testing/AvaloniaCollection.cs new file mode 100644 index 00000000..a90f7a0f --- /dev/null +++ b/GenLauncherGO.Tests/Testing/AvaloniaCollection.cs @@ -0,0 +1,4 @@ +namespace GenLauncherGO.Tests.Testing; + +[CollectionDefinition("Avalonia", DisableParallelization = true)] +public sealed class AvaloniaCollection; diff --git a/GenLauncherGO.Tests/Testing/CompletedGameProcessLaunchOperation.cs b/GenLauncherGO.Tests/Testing/CompletedGameProcessLaunchOperation.cs new file mode 100644 index 00000000..ad4bb684 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/CompletedGameProcessLaunchOperation.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class CompletedGameProcessLaunchOperation : IGameProcessLaunchOperation +{ + public CompletedGameProcessLaunchOperation(bool succeeded, string executableName) + { + CurrentExecutableName = executableName; + Completion = Task.FromResult(succeeded); + } + + public string CurrentExecutableName { get; } + + public Task Completion { get; } + + public event EventHandler? CurrentExecutableNameChanged + { + add { } + remove { } + } + + public void ForceClose() + { + } +} diff --git a/GenLauncherGO.Tests/Testing/ControllableGameProcessLaunchOperation.cs b/GenLauncherGO.Tests/Testing/ControllableGameProcessLaunchOperation.cs new file mode 100644 index 00000000..8a4ce7d7 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/ControllableGameProcessLaunchOperation.cs @@ -0,0 +1,83 @@ +using System; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Infrastructure.Launching.Support; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// A launched process the test decides the lifetime of, for the states the launcher only shows while a game is +/// running. +/// +internal sealed class ControllableGameProcessLaunchOperation : IGameProcessLaunchOperation +{ + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ControllableGameProcessLaunchOperation(string executableName = "generals.exe") + { + CurrentExecutableName = executableName; + Completion = _completion.Task; + } + + public string CurrentExecutableName { get; private set; } + + public Task Completion { get; set; } + + public event EventHandler? CurrentExecutableNameChanged; + + public void ForceClose() + { + } + + public void RaiseCurrentExecutableNameChanged(string executableName) + { + CurrentExecutableName = executableName; + CurrentExecutableNameChanged?.Invoke(this, EventArgs.Empty); + } + + public void Complete(bool succeeded) + { + _completion.TrySetResult(succeeded); + } +} + +/// +/// The counterpart, which reports elapsed run time rather than a +/// success flag. +/// +internal sealed class ControllableProcessFamilyLaunchOperation : IProcessFamilyLaunchOperation +{ + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ControllableProcessFamilyLaunchOperation(string executableName = "generals.exe") + { + CurrentExecutableName = executableName; + Completion = _completion.Task; + } + + public string CurrentExecutableName { get; private set; } + + public Task Completion { get; set; } + + public int ForceCloseCount { get; private set; } + + public event EventHandler? CurrentExecutableNameChanged; + + public void ForceClose() + { + ForceCloseCount++; + } + + public void RaiseCurrentExecutableNameChanged(string executableName) + { + CurrentExecutableName = executableName; + CurrentExecutableNameChanged?.Invoke(this, EventArgs.Empty); + } + + public void Complete(TimeSpan runningDuration) + { + _completion.TrySetResult(runningDuration); + } +} diff --git a/GenLauncherGO.Tests/Testing/ControllablePackageDownloadService.cs b/GenLauncherGO.Tests/Testing/ControllablePackageDownloadService.cs new file mode 100644 index 00000000..b2418563 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/ControllablePackageDownloadService.cs @@ -0,0 +1,114 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// A download that stays in flight until the test ends it, so a test can observe the launcher while a package +/// download is running. +/// +internal sealed class ControllablePackageDownloadService : IPackageDownloadService +{ + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private IProgress? _progress; + + /// + /// Completes once the download has begun. + /// + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + /// Completes once the download's cancellation token has been signalled. + /// + public TaskCompletionSource CancellationObserved { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public int CallCount { get; private set; } + + public async Task DownloadAsync( + LauncherContent modification, + LauncherContentVersion version, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + CallCount++; + _progress = progress; + using CancellationTokenRegistration registration = + cancellationToken.Register(() => CancellationObserved.TrySetResult()); + Started.TrySetResult(); + + PackageDownloadResult? result = await _completion.Task; + if (result is not null) + { + return result; + } + + return cancellationToken.IsCancellationRequested + ? PackageDownloadResult.Canceled() + : PackageDownloadResult.Succeeded(); + } + + public void Report(PackageUpdateProgress progress) + { + if (_progress is null) + { + throw new InvalidOperationException("The download has not started, so nothing observes progress yet."); + } + + _progress.Report(progress); + } + + /// + /// Ends the download with an explicit terminal result. + /// + public void Complete(PackageDownloadResult result) + { + _completion.TrySetResult(result); + } + + /// + /// Ends the download the way production would: canceled when the token was signalled, otherwise succeeded. + /// + public void Release() + { + _completion.TrySetResult(null); + } + + /// + /// Ends the download by faulting it, for the failure paths the launcher has to survive. + /// + public void Throw(Exception exception) + { + _completion.TrySetException(exception); + } +} + +/// +/// A download that returns its configured result immediately. +/// +internal sealed class StubPackageDownloadService : IPackageDownloadService +{ + private readonly PackageDownloadResult _result; + + public StubPackageDownloadService(PackageDownloadResult result) + { + _result = result ?? throw new ArgumentNullException(nameof(result)); + } + + public Task DownloadAsync( + LauncherContent modification, + LauncherContentVersion version, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + return Task.FromResult(_result); + } +} diff --git a/GenLauncherGO.Tests/Testing/CultureScope.cs b/GenLauncherGO.Tests/Testing/CultureScope.cs new file mode 100644 index 00000000..094cba70 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/CultureScope.cs @@ -0,0 +1,40 @@ +using System; +using System.Globalization; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Sets the culture for one test and restores what was there before. +/// +/// +/// The thread defaults are process-wide, so a test that changes them without restoring them changes the result of +/// every test that runs afterwards in the same process. +/// +internal sealed class CultureScope : IDisposable +{ + private readonly CultureInfo _previousCulture = CultureInfo.CurrentCulture; + private readonly CultureInfo? _previousDefaultThreadCulture = CultureInfo.DefaultThreadCurrentCulture; + private readonly CultureInfo? _previousDefaultThreadUiCulture = CultureInfo.DefaultThreadCurrentUICulture; + private readonly CultureInfo _previousUiCulture = CultureInfo.CurrentUICulture; + + public CultureScope(string? cultureName = null, string? uiCultureName = null) + { + if (cultureName is not null) + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(cultureName); + } + + if (uiCultureName is not null) + { + CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(uiCultureName); + } + } + + public void Dispose() + { + CultureInfo.CurrentCulture = _previousCulture; + CultureInfo.CurrentUICulture = _previousUiCulture; + CultureInfo.DefaultThreadCurrentCulture = _previousDefaultThreadCulture; + CultureInfo.DefaultThreadCurrentUICulture = _previousDefaultThreadUiCulture; + } +} diff --git a/GenLauncherGO.Tests/Testing/DeploymentJournalWriter.cs b/GenLauncherGO.Tests/Testing/DeploymentJournalWriter.cs new file mode 100644 index 00000000..8c44b3fc --- /dev/null +++ b/GenLauncherGO.Tests/Testing/DeploymentJournalWriter.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Launching.Support; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Writes the recovery journal an interrupted deployment would have left behind. +/// +internal static class DeploymentJournalWriter +{ + private const string JournalFileName = "journal.jsonl"; + + /// + /// Writes a journal whose header binds it to , followed by the supplied records. + /// + public static void Write(LauncherPaths paths, params DeploymentJournalRecord[] records) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(records); + + Directory.CreateDirectory(paths.DeploymentDirectory); + List journal = + [ + DeploymentJournalRecord.DeploymentStarted( + "crash", + PhysicalDirectoryPath.ResolveExisting(paths.GameDirectory), + DeploymentStateStore.GetGameRootIdentity(paths.GameDirectory), + paths.Game) + ]; + journal.AddRange(records); + + var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); + File.WriteAllLines( + Path.Combine(paths.DeploymentDirectory, JournalFileName), + journal.Select(record => JsonSerializer.Serialize(record, serializerOptions))); + } + + /// + /// Builds the fingerprint the deployment engine would have recorded for a file holding + /// . + /// + public static DeploymentFileFingerprint FingerprintFrom(string contents) + { + byte[] bytes = Encoding.UTF8.GetBytes(contents); + return new DeploymentFileFingerprint(bytes.Length, Convert.ToHexString(SHA256.HashData(bytes))); + } +} diff --git a/GenLauncherGO.Tests/Testing/FakeGameInstallationService.cs b/GenLauncherGO.Tests/Testing/FakeGameInstallationService.cs new file mode 100644 index 00000000..a6b3e3b7 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/FakeGameInstallationService.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Core.Startup.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Answers installation validation from a rule the test supplies, and records what it was asked about. +/// +/// +/// The default rule accepts any nonblank directory as its own canonical path, which is the arrangement almost +/// every startup test wants: the interesting behavior is what the caller does with the verdict, not how it was +/// reached. +/// +internal sealed class FakeGameInstallationService : IGameInstallationService +{ + public Func ValidationRule { get; init; } = + (_, directory) => string.IsNullOrWhiteSpace(directory) + ? GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.PathMissing) + : GameInstallationValidationResult.Valid(directory); + + /// + /// Returned from , or the supplied set when left unset. + /// + public LauncherInstallations? DiscoveredInstallations { get; set; } + + public GameInstallationLocation? ContainingInstallation { get; set; } + + public List<(SupportedGame Game, string? Directory, string ExecutableDirectory)> ValidateCalls { get; } = []; + + public List<(LauncherInstallations Current, string ExecutableDirectory)> DiscoverValidInstallationsCalls + { + get; + } = []; + + public GameInstallationLocation? FindContainingInstallation(string executableDirectory) + { + return ContainingInstallation; + } + + public GameInstallationValidationResult Validate( + SupportedGame game, + string? directory, + string executableDirectory) + { + ValidateCalls.Add((game, directory, executableDirectory)); + return ValidationRule(game, directory); + } + + public LauncherInstallations DiscoverValidInstallations( + LauncherInstallations current, + string executableDirectory) + { + DiscoverValidInstallationsCalls.Add((current, executableDirectory)); + return DiscoveredInstallations ?? current; + } +} diff --git a/GenLauncherGO.Tests/Testing/FakeHardLinkCreator.cs b/GenLauncherGO.Tests/Testing/FakeHardLinkCreator.cs new file mode 100644 index 00000000..2a5f1fab --- /dev/null +++ b/GenLauncherGO.Tests/Testing/FakeHardLinkCreator.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using GenLauncherGO.Infrastructure.Launching.Support; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Stands in for the Windows hard-link creator so a test can decide whether linking succeeds, what happens at the +/// staging path while a link is being made, and when the deployment is cancelled. +/// +internal sealed class FakeHardLinkCreator : IHardLinkCreator +{ + private readonly WindowsHardLinkCreator _windowsHardLinkCreator = new(); + + /// + /// Whether a link attempt is allowed to succeed at all. + /// + public bool CanCreateHardLinks { get; init; } = true; + + /// + /// Whether an allowed attempt creates a real hard link, so the deployment's own identity checks run. + /// + public bool UseRealHardLinks { get; init; } = true; + + public bool PathsOnSameVolume { get; init; } = true; + + /// + /// Observes a volume comparison, which is where a test can mutate the paths under the caller. + /// + public Action? SameVolumeCheck { get; init; } + + /// + /// Observes a link attempt before it is made, with the target and source paths. + /// + public Action? CreateHook { get; init; } + + /// + /// Cancelled after each link attempt, for the deployment cancellation paths. + /// + public CancellationTokenSource? CancelOn { get; init; } + + public List<(string TargetPath, string SourcePath)> CreatedLinks { get; } = []; + + public bool ArePathsOnSameVolume(string firstPath, string secondPath) + { + SameVolumeCheck?.Invoke(firstPath, secondPath); + return PathsOnSameVolume; + } + + public bool TryCreateHardLink(string targetPath, string sourcePath) + { + CreateHook?.Invoke(targetPath, sourcePath); + bool created = CanCreateHardLinks && + (!UseRealHardLinks || _windowsHardLinkCreator.TryCreateHardLink(targetPath, sourcePath)); + if (created) + { + CreatedLinks.Add((targetPath, sourcePath)); + } + + CancelOn?.Cancel(); + return created; + } +} diff --git a/GenLauncherGO.Tests/Testing/FakeLaunchPreparationService.cs b/GenLauncherGO.Tests/Testing/FakeLaunchPreparationService.cs new file mode 100644 index 00000000..0e25a356 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/FakeLaunchPreparationService.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Records launch preparation and can hold open, which is how a test observes the +/// launcher's busy state without racing a real deployment. +/// +internal sealed class FakeLaunchPreparationService : ILaunchPreparationService +{ + private TaskCompletionSource _resume = CreateResumedSource(); + + public List PrepareRequests { get; } = []; + + public List CleanupRequests { get; } = []; + + /// + /// Completes once has been entered. + /// + public TaskCompletionSource PrepareStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public bool PrepareResult { get; init; } = true; + + public bool CleanupResult { get; init; } = true; + + /// + /// Holds the next call until is called. + /// + public void Pause() + { + _resume = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public void Resume() + { + _resume.TrySetResult(); + } + + public bool Prepare(LaunchPreparationRequest request, CancellationToken cancellationToken) + { + PrepareRequests.Add(request); + PrepareStarted.TrySetResult(); + _resume.Task.Wait(cancellationToken); + return PrepareResult; + } + + public bool Cleanup(LauncherPaths paths, CancellationToken cancellationToken) + { + CleanupRequests.Add(paths); + return CleanupResult; + } + + public bool Recover(LauncherPaths paths, CancellationToken cancellationToken) + { + return true; + } + + private static TaskCompletionSource CreateResumedSource() + { + var source = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + source.SetResult(); + return source; + } +} diff --git a/GenLauncherGO.Tests/Testing/FakeLauncherContentCatalog.cs b/GenLauncherGO.Tests/Testing/FakeLauncherContentCatalog.cs new file mode 100644 index 00000000..fa2863d1 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/FakeLauncherContentCatalog.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class FakeLauncherContentCatalog : ILauncherContentCatalog +{ + private readonly HashSet _repositoryContent = []; + + public List InitializationRequests { get; } = []; + + public List DownloadRequests { get; } = []; + + public List ChildManifestRequests { get; } = []; + + public List UninstalledVersions { get; } = []; + + public List DiscardedVersions { get; } = []; + + public List DiscardedContents { get; } = []; + + public int OriginalGameChildManifestReadCount { get; private set; } + + public int LocalDataUpdateCount { get; private set; } + + public int SaveCount { get; private set; } + + public Func? InitializationHandler + { + get; + set; + } + + public Func? OriginalGameChildManifestReadHandler { get; set; } + + public Func>? DownloadHandler { get; set; } + + public Func>? MetadataHandler { get; set; } + + public Func? ChildManifestReadHandler { get; set; } + + public Action? UninstallVersionHandler { get; set; } + + public Action? LocalDataUpdateHandler { get; set; } + + public Action? SaveHandler { get; set; } + + public LauncherData Data { get; set; } = new(); + + public LauncherContentVersion? Advertising { get; set; } + + public IReadOnlyList? RepositoryModificationNames { get; set; } + + public Task InitDataAsync( + LauncherContentCatalogInitializationRequest request, + CancellationToken cancellationToken) + { + InitializationRequests.Add(request); + return InitializationHandler?.Invoke(request, cancellationToken) ?? Task.CompletedTask; + } + + public Task ReadOriginalGameAddonsAndPatchesAsync(CancellationToken cancellationToken) + { + OriginalGameChildManifestReadCount++; + return OriginalGameChildManifestReadHandler?.Invoke(cancellationToken) ?? Task.CompletedTask; + } + + public Task GetRepositoryModificationMetadataAsync( + string name, + CancellationToken cancellationToken) + { + return MetadataHandler?.Invoke(name, cancellationToken) ?? + Task.FromException( + new InvalidOperationException($"No metadata result was configured for '{name}'.")); + } + + public async Task AddRepositoryModificationAsync( + string name, + CancellationToken cancellationToken) + { + DownloadRequests.Add(name); + LauncherContentVersion modification = await (DownloadHandler?.Invoke(name, cancellationToken) ?? + Task.FromException( + new InvalidOperationException( + $"No download result was configured for '{name}'."))); + Data.AddOrUpdate(modification); + _repositoryContent.Add(modification.ContentKey); + return modification; + } + + public Task ReadPatchesAndAddonsForModAsync( + LauncherContentKey modificationKey, + CancellationToken cancellationToken) + { + ChildManifestRequests.Add(modificationKey); + return ChildManifestReadHandler?.Invoke(modificationKey, cancellationToken) ?? Task.CompletedTask; + } + + public void UninstallVersion(LauncherContentKey contentKey) + { + UninstalledVersions.Add(contentKey); + LauncherContentVersion? version = Data.FindContent(contentKey)?.Versions + .FirstOrDefault(candidate => candidate.ContentKey == contentKey); + if (version is not null) + { + if (_repositoryContent.Contains(contentKey) || + version.EffectiveContentSourceKind.IsManagedRemote()) + { + version.Installation.Installed = false; + } + else + { + Data.DeleteVersion(contentKey); + } + } + + UninstallVersionHandler?.Invoke(contentKey); + UpdateLocalModificationsData(); + } + + public void DiscardVersion(LauncherContentKey contentKey) + { + DiscardedVersions.Add(contentKey); + Data.DeleteVersion(contentKey); + _repositoryContent.Remove(contentKey); + UpdateLocalModificationsData(); + } + + public void DiscardContent(LauncherContentKey contentKey) + { + DiscardedContents.Add(contentKey); + Data.DeleteContent(contentKey); + _repositoryContent.RemoveWhere(candidate => + candidate.ContentType == contentKey.ContentType && + string.Equals(candidate.ParentIdentity, contentKey.ParentIdentity, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.Name, contentKey.Name, StringComparison.OrdinalIgnoreCase)); + UpdateLocalModificationsData(); + } + + public void UpdateLocalModificationsData() + { + LocalDataUpdateCount++; + LocalDataUpdateHandler?.Invoke(); + } + + public void SaveLauncherData() + { + SaveCount++; + SaveHandler?.Invoke(); + } +} diff --git a/GenLauncherGO.Tests/Testing/FakeModificationThemeCache.cs b/GenLauncherGO.Tests/Testing/FakeModificationThemeCache.cs new file mode 100644 index 00000000..ce9ebc95 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/FakeModificationThemeCache.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Keeps cached palettes in memory so tests can assert what was cached without touching disk. +/// +internal sealed class FakeModificationThemeCache : IModificationThemeCache +{ + private readonly Dictionary _entries = []; + + public IReadOnlyDictionary Entries => _entries; + + public void Save(LauncherContentKey contentKey, LauncherContentTheme theme) + { + _entries[contentKey] = theme; + } + + public LauncherContentTheme? Load(LauncherContentKey contentKey) + { + return _entries.TryGetValue(contentKey, out LauncherContentTheme? theme) ? theme : null; + } +} diff --git a/GenLauncherGO.Tests/Testing/FakeStringLocalizer.cs b/GenLauncherGO.Tests/Testing/FakeStringLocalizer.cs new file mode 100644 index 00000000..d1e5370c --- /dev/null +++ b/GenLauncherGO.Tests/Testing/FakeStringLocalizer.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.UI.Shared.Localization; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Resolves test localization keys from an in-memory dictionary whose keys must exist in the shipped neutral +/// resource. +/// +/// +/// Validating the keys is what stops a fixture from asserting text the product could never produce: a test that +/// seeds a key nobody ships still passes while the feature it claims to cover is broken. +/// +internal sealed class FakeStringLocalizer : ILauncherStringLocalizer +{ + /// + /// Creates a fallback value for missing keys. + /// + private readonly Func _fallback; + + /// + /// The configured localized values. + /// + private readonly IReadOnlyDictionary _values; + + /// + /// Initializes a new instance of the class. + /// + public FakeStringLocalizer() + : this(new Dictionary(StringComparer.Ordinal) + { + ["LatestVersion"] = "Latest version: " + }) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The explicit localized values. + public FakeStringLocalizer(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 FakeStringLocalizer( + IReadOnlyDictionary values, + Func fallback) + { + ArgumentNullException.ThrowIfNull(values); + ArgumentNullException.ThrowIfNull(fallback); + + ValidateKeys(values); + + _values = values; + _fallback = fallback; + } + + /// + public string this[string key] => _values.TryGetValue(key, out string? value) ? value : _fallback(key); + + /// + /// Builds a localizer from a shared set, replacing only the values this test is asserting on. + /// + public static FakeStringLocalizer Create( + IReadOnlyDictionary baseSet, + params (string Key, string Value)[] overrides) + { + ArgumentNullException.ThrowIfNull(baseSet); + ArgumentNullException.ThrowIfNull(overrides); + + var values = new Dictionary(baseSet, StringComparer.Ordinal); + foreach ((string key, string value) in overrides) + { + values[key] = value; + } + + return new FakeStringLocalizer(values); + } + + private static void ValidateKeys(IReadOnlyDictionary values) + { + var unknownKeys = values.Keys + .Where(key => !LocalizationResourceKeys.Contains(key)) + .Order(StringComparer.Ordinal) + .ToList(); + if (unknownKeys.Count == 0) + { + return; + } + + throw new ArgumentException( + "These localization keys are not in Strings.resx, so no production caller can ask for them: " + + $"{string.Join(", ", unknownKeys)}. Assert on a key the launcher actually ships.", + nameof(values)); + } +} diff --git a/GenLauncherGO.Tests/Testing/LocalizationResourceKeys.cs b/GenLauncherGO.Tests/Testing/LocalizationResourceKeys.cs new file mode 100644 index 00000000..8e7d2116 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/LocalizationResourceKeys.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Reads the neutral localization resource once and caches its key set. +/// +/// +/// This is the authority a test localizer validates against, so a fixture cannot invent a key the product does +/// not ship and then assert a value nothing would ever produce. +/// +internal static class LocalizationResourceKeys +{ + private const string NeutralResourceFileName = "Strings.resx"; + + private const string ResourceFolderName = "LocalizationResources"; + + private static readonly Lazy> _keys = new(ReadNeutralKeys); + + public static IReadOnlySet All => _keys.Value; + + public static bool Contains(string key) + { + return All.Contains(key); + } + + private static IReadOnlySet ReadNeutralKeys() + { + string resourceFilePath = Path.Combine( + AppContext.BaseDirectory, + ResourceFolderName, + NeutralResourceFileName); + // Loaded through a stream rather than the path overload: that overload routes the path through + // System.Uri, which rejects a path long enough to exceed MAX_PATH with an unrelated + // "hostname could not be parsed" error. Test checkouts sit at arbitrary depths. + using FileStream resourceStream = File.OpenRead(resourceFilePath); + var document = XDocument.Load(resourceStream); + XElement root = document.Root ?? + throw new InvalidDataException($"{NeutralResourceFileName} has no root element."); + + return root.Elements("data") + .Select(element => element.Attribute("name")?.Value ?? + throw new InvalidDataException( + $"{NeutralResourceFileName} contains an unnamed resource.")) + .ToHashSet(StringComparer.Ordinal); + } +} diff --git a/GenLauncherGO.Tests/Testing/ManualTimeProvider.cs b/GenLauncherGO.Tests/Testing/ManualTimeProvider.cs new file mode 100644 index 00000000..a92c40f6 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/ManualTimeProvider.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// A clock that only moves when a test advances it. +/// +/// +/// Serves both the elapsed-time readings taken through and the wall-clock +/// readings taken through , so one fake covers every production clock seam. +/// +internal sealed class ManualTimeProvider : TimeProvider +{ + private readonly DateTimeOffset _startUtc; + + private long _timestamp; + + public ManualTimeProvider() + : this(new DateTimeOffset(2026, 6, 21, 12, 0, 0, TimeSpan.Zero)) + { + } + + public ManualTimeProvider(DateTimeOffset startUtc) + { + _startUtc = startUtc; + } + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override long GetTimestamp() + { + return Interlocked.Read(ref _timestamp); + } + + public override DateTimeOffset GetUtcNow() + { + return _startUtc.AddTicks(Interlocked.Read(ref _timestamp)); + } + + public void Advance(TimeSpan elapsed) + { + ArgumentOutOfRangeException.ThrowIfLessThan(elapsed, TimeSpan.Zero); + + Interlocked.Add(ref _timestamp, elapsed.Ticks); + } +} diff --git a/GenLauncherGO.Tests/Testing/QueueHttpMessageHandler.cs b/GenLauncherGO.Tests/Testing/QueueHttpMessageHandler.cs new file mode 100644 index 00000000..82ab744f --- /dev/null +++ b/GenLauncherGO.Tests/Testing/QueueHttpMessageHandler.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class QueueHttpMessageHandler : HttpMessageHandler +{ + private readonly Queue> _responses = new(); + + public IEnumerable Methods => Requests.Select(request => request.Method); + + public IEnumerable RangeHeaders => + Requests.Select(request => request.Headers.Range?.ToString()); + + public List Requests { get; } = []; + + public void Enqueue(Func responseFactory) + { + ArgumentNullException.ThrowIfNull(responseFactory); + + _responses.Enqueue(responseFactory); + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Requests.Add(request); + if (!_responses.TryDequeue(out Func? responseFactory)) + { + throw new InvalidOperationException( + $"No response was configured for {request.Method} {request.RequestUri}."); + } + + return Task.FromResult(responseFactory(request)); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingArchiveExtractor.cs b/GenLauncherGO.Tests/Testing/RecordingArchiveExtractor.cs new file mode 100644 index 00000000..58da9123 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingArchiveExtractor.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading; +using GenLauncherGO.Infrastructure.Archives.Contracts; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Records what was asked of the extractor and lets the test decide what lands in the destination. +/// +internal sealed class RecordingArchiveExtractor : IArchiveExtractor +{ + public string? ArchiveFilePath { get; private set; } + + public string? DestinationDirectory { get; private set; } + + public bool? ConvertBigFilesToGib { get; private set; } + + /// + /// Populates the destination directory, which is given as its only argument. + /// + public Action? ExtractHandler { get; init; } + + public void ExtractToDirectory( + string archiveFilePath, + string destinationDirectory, + bool convertBigFilesToGib = false, + CancellationToken cancellationToken = default) + { + ArchiveFilePath = archiveFilePath; + DestinationDirectory = destinationDirectory; + ConvertBigFilesToGib = convertBigFilesToGib; + ExtractHandler?.Invoke(destinationDirectory); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingAtomicFileWriter.cs b/GenLauncherGO.Tests/Testing/RecordingAtomicFileWriter.cs new file mode 100644 index 00000000..3ecf0db3 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingAtomicFileWriter.cs @@ -0,0 +1,46 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Persistence.Services; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class RecordingAtomicFileWriter : IAtomicFileWriter +{ + public string? DestinationPath { get; private set; } + + public string? Contents { get; private set; } + + public CancellationToken? CancellationToken { get; private set; } + + public bool WasWriteAsyncCalled { get; private set; } + + public void WriteText(string destinationPath, string contents) + { + throw new NotSupportedException("This recorder only observes WriteAsync."); + } + + public async Task WriteAsync( + string destinationPath, + Func writeTemporaryFileAsync, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + await using var stream = new MemoryStream(); + await writeTemporaryFileAsync(stream, cancellationToken); + DestinationPath = destinationPath; + Contents = Encoding.UTF8.GetString(stream.ToArray()); + CancellationToken = cancellationToken; + WasWriteAsyncCalled = true; + } + + public Task WriteFileIfMissingAsync( + string destinationPath, + Func writeTemporaryFileAsync, + CancellationToken cancellationToken) + { + throw new NotSupportedException("This recorder only observes WriteAsync."); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingFileDownloader.cs b/GenLauncherGO.Tests/Testing/RecordingFileDownloader.cs new file mode 100644 index 00000000..6e412167 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingFileDownloader.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Records download requests and, unless a handler takes over, writes a file of the requested length so the +/// caller's own file handling runs against real bytes. +/// +internal sealed class RecordingFileDownloader : IResumableFileDownloader +{ + private const byte FillerByte = (byte)'x'; + + public ConcurrentQueue Requests { get; } = new(); + + public Func? Handler { get; init; } + + public Func?, CancellationToken, Task>? ProgressHandler + { + get; + init; + } + + public async Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + Requests.Enqueue(request); + if (ProgressHandler is not null) + { + await ProgressHandler(request, progress, cancellationToken); + return; + } + + if (Handler is not null) + { + await Handler(request, cancellationToken); + return; + } + + byte[] payload = new byte[checked((int)request.ExpectedBytes.GetValueOrDefault())]; + Array.Fill(payload, FillerByte); + Directory.CreateDirectory(Path.GetDirectoryName(request.DestinationFilePath)!); + await File.WriteAllBytesAsync(request.DestinationFilePath, payload, cancellationToken); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingLauncherContentStateStore.cs b/GenLauncherGO.Tests/Testing/RecordingLauncherContentStateStore.cs new file mode 100644 index 00000000..1364a950 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingLauncherContentStateStore.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Models; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class RecordingLauncherContentStateStore : ILauncherContentStateStore +{ + public LauncherContentState StateToLoad { get; set; } = new(); + + public Dictionary StatesToLoadByGame { get; } = []; + + public List LoadedPaths { get; } = []; + + public List SavedStates { get; } = []; + + public List SavedPaths { get; } = []; + + public Action? SaveHandler { get; set; } + + public LauncherContentState Load(LauncherPaths paths) + { + LoadedPaths.Add(paths); + return StatesToLoadByGame.TryGetValue(paths.Game, out LauncherContentState? state) + ? state + : StateToLoad; + } + + public void Save(LauncherPaths paths, LauncherContentState state) + { + SavedPaths.Add(paths); + SavedStates.Add(state); + SaveHandler?.Invoke(state); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingLauncherDialogService.cs b/GenLauncherGO.Tests/Testing/RecordingLauncherDialogService.cs new file mode 100644 index 00000000..4294c43a --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingLauncherDialogService.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Controls; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Records every dialog the launcher raised and answers each one from a configured result. +/// +internal sealed class RecordingLauncherDialogService : ILauncherDialogService +{ + public List InfoRequests { get; } = []; + + public List ErrorRequests { get; } = []; + + public List<(LauncherInfoDialogRequest Request, string? ContinueText)> WarningConfirmationRequests { get; } = []; + + public List IntegrityReviewRequests { get; } = []; + + public bool WarningConfirmationResult { get; init; } + + public bool IntegrityReviewResult { get; init; } + + public Task ShowInfoAsync(LauncherInfoDialogRequest request, Window? owner = null) + { + InfoRequests.Add(request); + return Task.CompletedTask; + } + + public Task ShowInfoActionAsync( + LauncherInfoDialogRequest request, + string actionText, + Window? owner = null) + { + return Task.FromException(Unexpected(nameof(ShowInfoActionAsync))); + } + + public Task ShowErrorAsync(LauncherInfoDialogRequest request, Window? owner = null) + { + ErrorRequests.Add(request); + return Task.CompletedTask; + } + + public Task ShowWarningConfirmationAsync( + LauncherInfoDialogRequest request, + string? continueText = null, + Window? owner = null) + { + WarningConfirmationRequests.Add((request, continueText)); + return Task.FromResult(WarningConfirmationResult); + } + + public Task ShowModificationSelectionAsync( + IReadOnlyList modificationNames, + Window? owner = null) + { + return Task.FromException(Unexpected(nameof(ShowModificationSelectionAsync))); + } + + public Task ShowManualModificationImportAsync( + IReadOnlyList files, + Window? owner = null) + { + return Task.FromException( + Unexpected(nameof(ShowManualModificationImportAsync))); + } + + public Task ShowIntegrityReviewAsync(ContentIntegrityReport report, Window? owner = null) + { + IntegrityReviewRequests.Add(report); + return Task.FromResult(IntegrityReviewResult); + } + + /// + /// Fails a test that raised a dialog this fake answers for nobody, rather than handing back a default that + /// reads like a real user choice. Give the dialog a recorded result here once a test actually needs one. + /// + private static InvalidOperationException Unexpected(string dialogName) + { + return new InvalidOperationException($"No test configures {dialogName} on this fake."); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingLauncherPreferencesService.cs b/GenLauncherGO.Tests/Testing/RecordingLauncherPreferencesService.cs new file mode 100644 index 00000000..8a812cff --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingLauncherPreferencesService.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Core.Settings.Exceptions; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Infrastructure.Settings.Support; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Records preference updates and applies the same no-op policy as PreferencesService.Update: a value that +/// normalizes to what is already current is neither recorded nor announced. +/// +/// +/// Normalization decides only whether the update is a change. What is recorded is the value the caller passed, +/// because a test seeds directly and a fake that silently repaired those values would hide +/// the state the caller is being tested against. +/// +internal sealed class RecordingLauncherPreferencesService : ILauncherPreferencesService +{ + public RecordingLauncherPreferencesService(LauncherPreferences current) + { + Current = current; + } + + public LauncherPreferences Current { get; private set; } + + public List Updates { get; } = []; + + public int UpdateCount => Updates.Count; + + public LauncherPreferencesPersistenceException? UpdateFailure { get; init; } + + public event EventHandler? PreferencesChanged; + + public void Update(LauncherPreferences preferences) + { + ArgumentNullException.ThrowIfNull(preferences); + + if (LauncherPreferencesDocumentMapper.Normalize(preferences) == + LauncherPreferencesDocumentMapper.Normalize(Current)) + { + return; + } + + if (UpdateFailure is not null) + { + throw UpdateFailure; + } + + Current = preferences; + Updates.Add(preferences); + PreferencesChanged?.Invoke(this, preferences); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingLocalLauncherContentService.cs b/GenLauncherGO.Tests/Testing/RecordingLocalLauncherContentService.cs new file mode 100644 index 00000000..716a3622 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingLocalLauncherContentService.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class RecordingLocalLauncherContentService : ILocalLauncherContentService +{ + public IReadOnlyList InstalledVersions { get; set; } = + Array.Empty(); + + public List<(LauncherPaths Paths, LauncherContentKey ContentKey)> DeletedVersions { get; } = []; + + public List<(LauncherPaths Paths, LauncherContentKey ContentKey)> DeletedContents { get; } = []; + + public List EmptyPackageBackupCleanupRequests { get; } = []; + + /// + /// Records image-deletion requests with the catalog names as they stood when the request was made. + /// + /// + /// Data is a live reference the caller keeps mutating, so only ContentNames can answer what the + /// catalog looked like at the moment deletion was decided. Data is retained for identity assertions. + /// + public List<( + LauncherPaths Paths, + LauncherContentKey ContentKey, + LauncherData Data, + IReadOnlyList ContentNames)> ImageDeletionRequests + { get; } = []; + + public IReadOnlyList FindInstalledVersions(LauncherPaths paths) + { + return InstalledVersions; + } + + public void DeleteEmptyPackageBackupDirectories(LauncherPaths paths) + { + EmptyPackageBackupCleanupRequests.Add(paths); + } + + public void DeleteVersion(LauncherPaths paths, LauncherContentKey contentKey) + { + DeletedVersions.Add((paths, contentKey)); + } + + public void DeleteContent(LauncherPaths paths, LauncherContentKey contentKey) + { + DeletedContents.Add((paths, contentKey)); + } + + public void DeleteImagesIfUnused( + LauncherPaths paths, + LauncherContentKey contentKey, + LauncherData launcherData) + { + ImageDeletionRequests.Add((paths, contentKey, launcherData, SnapshotContentNames(launcherData))); + } + + private static IReadOnlyList SnapshotContentNames(LauncherData launcherData) + { + return launcherData.Modifications + .Concat(launcherData.Patches) + .Concat(launcherData.Addons) + .Select(content => content.Name) + .ToList(); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingLogger.cs b/GenLauncherGO.Tests/Testing/RecordingLogger.cs new file mode 100644 index 00000000..f2196d3f --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingLogger.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class RecordingLogger : ILogger +{ + public List Entries { get; } = []; + + public IDisposable BeginScope(TState state) + where TState : notnull + { + return NullScope.Instance; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Entries.Add(new RecordingLogEntry(logLevel, formatter(state, exception), exception)); + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } +} + +internal sealed record RecordingLogEntry( + LogLevel LogLevel, + string Message, + Exception? Exception); diff --git a/GenLauncherGO.Tests/Testing/RecordingProgress.cs b/GenLauncherGO.Tests/Testing/RecordingProgress.cs new file mode 100644 index 00000000..53a91a5e --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingProgress.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Collects progress reports. Production reports from whatever thread the transfer is on, so the reports are +/// collected through a concurrent queue rather than a list. +/// +internal sealed class RecordingProgress : IProgress +{ + private readonly ConcurrentQueue _reports = new(); + + public IReadOnlyList Reports => _reports.ToArray(); + + public void Report(T value) + { + _reports.Enqueue(value); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingRemoteAssetDownloader.cs b/GenLauncherGO.Tests/Testing/RecordingRemoteAssetDownloader.cs new file mode 100644 index 00000000..c79223c0 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingRemoteAssetDownloader.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Remote.Contracts; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class RecordingRemoteAssetDownloader : IRemoteAssetDownloader +{ + private readonly ConcurrentQueue<(Uri SourceUri, string DestinationFilePath)> _calls = new(); + + public IReadOnlyList<(Uri SourceUri, string DestinationFilePath)> Calls => _calls.ToArray(); + + public Func? Handler { get; set; } + + public Task DownloadIfMissingAsync( + Uri sourceUri, + string destinationFilePath, + CancellationToken cancellationToken) + { + _calls.Enqueue((sourceUri, destinationFilePath)); + return Handler?.Invoke(sourceUri, destinationFilePath, cancellationToken) ?? Task.CompletedTask; + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingS3ObjectManifestReader.cs b/GenLauncherGO.Tests/Testing/RecordingS3ObjectManifestReader.cs new file mode 100644 index 00000000..f173460b --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingS3ObjectManifestReader.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Returns enqueued manifests in order and records what was asked for. +/// +/// +/// Once the queue drains, the last manifest is returned again: listing the same prefix twice has to produce the +/// same objects, so a caller that re-reads must not silently see an empty bucket. +/// +internal sealed class RecordingS3ObjectManifestReader : IS3ObjectManifestReader +{ + private readonly Queue> _manifests = new(); + + private IReadOnlyList _lastManifest = Array.Empty(); + + public List Requests { get; } = []; + + public void Enqueue(params RemoteFileManifestEntry[] files) + { + ArgumentNullException.ThrowIfNull(files); + + _manifests.Enqueue(files); + } + + public Task> ReadManifestAsync( + S3ObjectManifestRequest request, + CancellationToken cancellationToken) + { + Requests.Add(request); + if (_manifests.TryDequeue(out IReadOnlyList? manifest)) + { + _lastManifest = manifest; + } + + return Task.FromResult(_lastManifest); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingS3PackageUpdater.cs b/GenLauncherGO.Tests/Testing/RecordingS3PackageUpdater.cs new file mode 100644 index 00000000..7dc9e584 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingS3PackageUpdater.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Records both S3 package operations, which are separate contracts a caller can confuse: a full update replaces +/// the installed folder, a repair rewrites named files in place. +/// +internal sealed class RecordingS3PackageUpdater : IS3PackageUpdater +{ + public List UpdateRequests { get; } = []; + + public List RepairRequests { get; } = []; + + public List PauseControllers { get; } = []; + + /// + /// Reported once from each operation when set. + /// + public PackageUpdateProgress? ProgressToReport { get; init; } + + public Task UpdateAsync( + S3PackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + UpdateRequests.Add(request); + PauseControllers.Add(pauseController); + ReportProgress(progress); + return Task.CompletedTask; + } + + public Task RepairFilesAsync( + S3PackageFileRepairRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + RepairRequests.Add(request); + ReportProgress(progress); + return Task.CompletedTask; + } + + private void ReportProgress(IProgress? progress) + { + if (ProgressToReport is not null) + { + progress?.Report(ProgressToReport); + } + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingSingleFilePackageUpdater.cs b/GenLauncherGO.Tests/Testing/RecordingSingleFilePackageUpdater.cs new file mode 100644 index 00000000..c15b9ffd --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingSingleFilePackageUpdater.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Records single-file package updates, including the pause controller the caller forwarded, which is the only +/// evidence that a suspended download can be resumed. +/// +internal sealed class RecordingSingleFilePackageUpdater : ISingleFilePackageUpdater +{ + public List<(DownloadFileMetadata Metadata, PackageUpdatePathSet Paths)> Requests { get; } = []; + + public List PauseControllers { get; } = []; + + /// + /// Reported once from each update when set. + /// + public PackageUpdateProgress? ProgressToReport { get; init; } + + /// + /// Takes over the update body when set, for the cancellation and failure paths. + /// + public Func?, CancellationToken, Task>? Update { get; init; } + + public Task UpdateAsync( + DownloadFileMetadata metadata, + PackageUpdatePathSet paths, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + Requests.Add((metadata, paths)); + PauseControllers.Add(pauseController); + if (ProgressToReport is not null) + { + progress?.Report(ProgressToReport); + } + + return Update?.Invoke(metadata, progress, cancellationToken) ?? Task.CompletedTask; + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingStartupDialogService.cs b/GenLauncherGO.Tests/Testing/RecordingStartupDialogService.cs new file mode 100644 index 00000000..3e087550 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingStartupDialogService.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using GenLauncherGO.UI.Features.Startup.Contracts; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Records the startup dialogs the launcher showed before the main dialog service exists. +/// +internal sealed class RecordingStartupDialogService : IStartupDialogService +{ + /// + /// Every message shown, whatever dialog carried it. + /// + public List Messages { get; } = []; + + /// + /// The titled messages only, so a test can assert the title as well as the body. + /// + public List<(string Title, string Message)> TitledMessages { get; } = []; + + public List<(string Title, string Message)> RetryCancelWarnings { get; } = []; + + /// + /// The answer every retry prompt gets. + /// + public bool RetryResult { get; init; } + + public Task ShowMessageAsync(string message) + { + Messages.Add(message); + return Task.CompletedTask; + } + + public Task ShowMessageAsync(string title, string message) + { + Messages.Add(message); + TitledMessages.Add((title, message)); + return Task.CompletedTask; + } + + public Task ShowRetryCancelWarningAsync(string title, string message) + { + Messages.Add(message); + RetryCancelWarnings.Add((title, message)); + return Task.FromResult(RetryResult); + } +} diff --git a/GenLauncherGO.Tests/Testing/ReparsePointTestSupport.cs b/GenLauncherGO.Tests/Testing/ReparsePointTestSupport.cs new file mode 100644 index 00000000..ae5fffdc --- /dev/null +++ b/GenLauncherGO.Tests/Testing/ReparsePointTestSupport.cs @@ -0,0 +1,172 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Creates directory junctions for the reparse-point safety tests. +/// +/// Production rejects unsafe paths by testing and never inspects the +/// reparse tag, so a junction exercises the same checks as a symbolic link. Junctions need no elevation and no +/// Windows Developer Mode, which keeps these safety tests running for every contributor instead of skipping on +/// accounts that cannot create symbolic links. +/// +/// +internal static class ReparsePointTestSupport +{ + private const uint IoReparseTagMountPoint = 0xA0000003; + private const uint FsctlSetReparsePoint = 0x000900A4; + private const uint GenericWrite = 0x40000000; + private const uint OpenExisting = 3; + private const uint FileFlagBackupSemantics = 0x02000000; + private const uint FileFlagOpenReparsePoint = 0x00200000; + + /// + /// Creates a directory junction at that resolves to + /// . + /// + public static void CreateDirectoryJunction(string junctionPath, string targetPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(junctionPath); + ArgumentException.ThrowIfNullOrWhiteSpace(targetPath); + + string fullTargetPath = Path.GetFullPath(targetPath); + Directory.CreateDirectory(fullTargetPath); + Directory.CreateDirectory(junctionPath); + + byte[] reparseData = BuildMountPointReparseData(fullTargetPath); + + using SafeFileHandle handle = CreateFile( + junctionPath, + GenericWrite, + 0, + IntPtr.Zero, + OpenExisting, + FileFlagBackupSemantics | FileFlagOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + throw new Win32Exception( + Marshal.GetLastWin32Error(), + $"Could not open '{junctionPath}' to write its junction data."); + } + + if (!DeviceIoControl( + handle, + FsctlSetReparsePoint, + reparseData, + reparseData.Length, + IntPtr.Zero, + 0, + out int _, + IntPtr.Zero)) + { + throw new Win32Exception( + Marshal.GetLastWin32Error(), + $"Could not set the junction reparse point on '{junctionPath}'."); + } + } + + /// + /// Creates a junction at pointing at a directory outside the launcher-owned + /// tree that holds one canary file, which is the arrangement every containment test needs. + /// + /// + /// The canary is what proves a rejected operation stopped before it followed the link: asserting only that the + /// junction survived cannot tell a refusal apart from a delete that happened to leave the link behind. + /// + public static ProtectedJunction CreateJunctionToProtectedTarget( + TestDirectory directory, + string junctionPath, + string targetRelativePath = "ExternalTarget", + string canaryFileName = "target.txt") + { + ArgumentNullException.ThrowIfNull(directory); + ArgumentException.ThrowIfNullOrWhiteSpace(junctionPath); + + const string CanaryContents = "target"; + + string targetDirectory = directory.CreateDirectory(targetRelativePath); + string canaryFilePath = directory.CreateFile( + Path.Combine(targetRelativePath, canaryFileName), + CanaryContents); + CreateDirectoryJunction(junctionPath, targetDirectory); + + return new ProtectedJunction(junctionPath, targetDirectory, canaryFilePath, CanaryContents); + } + + /// + /// Builds a REPARSE_DATA_BUFFER describing a mount point. The substitute name uses the NT object-manager prefix + /// that the mount-point format requires; the print name is the display path. + /// + private static byte[] BuildMountPointReparseData(string fullTargetPath) + { + const int ReparseHeaderLength = 8; + const int MountPointHeaderLength = 8; + const int TerminatorLength = 2; + + byte[] substituteName = Encoding.Unicode.GetBytes($@"\??\{fullTargetPath}"); + byte[] printName = Encoding.Unicode.GetBytes(fullTargetPath); + + int pathBufferLength = substituteName.Length + TerminatorLength + printName.Length + TerminatorLength; + byte[] buffer = new byte[ReparseHeaderLength + MountPointHeaderLength + pathBufferLength]; + + BitConverter.GetBytes(IoReparseTagMountPoint).CopyTo(buffer, 0); + BitConverter.GetBytes((ushort)(MountPointHeaderLength + pathBufferLength)).CopyTo(buffer, 4); + BitConverter.GetBytes((ushort)0).CopyTo(buffer, 6); + BitConverter.GetBytes((ushort)0).CopyTo(buffer, 8); + BitConverter.GetBytes((ushort)substituteName.Length).CopyTo(buffer, 10); + BitConverter.GetBytes((ushort)(substituteName.Length + TerminatorLength)).CopyTo(buffer, 12); + BitConverter.GetBytes((ushort)printName.Length).CopyTo(buffer, 14); + + substituteName.CopyTo(buffer, ReparseHeaderLength + MountPointHeaderLength); + printName.CopyTo( + buffer, + ReparseHeaderLength + MountPointHeaderLength + substituteName.Length + TerminatorLength); + + return buffer; + } + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern SafeFileHandle CreateFile( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + IntPtr lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DeviceIoControl( + SafeFileHandle hDevice, + uint dwIoControlCode, + byte[] lpInBuffer, + int nInBufferSize, + IntPtr lpOutBuffer, + int nOutBufferSize, + out int lpBytesReturned, + IntPtr lpOverlapped); +} + +/// +/// A junction and the outside directory it resolves to, with the canary that proves the target was untouched. +/// +internal sealed record ProtectedJunction( + string JunctionPath, + string TargetDirectory, + string CanaryFilePath, + string CanaryContents) +{ + /// + /// Reads the canary as it stands now, which must still equal . + /// + public string ReadCanary() + { + return File.ReadAllText(CanaryFilePath); + } +} diff --git a/GenLauncherGO.Tests/Testing/StaTestRunner.cs b/GenLauncherGO.Tests/Testing/StaTestRunner.cs new file mode 100644 index 00000000..b9387301 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StaTestRunner.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Headless; +using GenLauncherGO.UI.Features.Startup; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Runs Avalonia-dependent test code in an isolated headless UI session. +/// +internal static class StaTestRunner +{ + private static readonly Lock _sessionGate = new(); + + private static readonly Lazy _session = + new(() => HeadlessUnitTestSession.StartNew(typeof(LauncherAvaloniaApplication))); + + public static void Run(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + lock (_sessionGate) + { + _session.Value.Dispatch(action, CancellationToken.None).GetAwaiter().GetResult(); + } + } + + public static void Run(Func action) + { + ArgumentNullException.ThrowIfNull(action); + + lock (_sessionGate) + { + _session.Value.Dispatch( + async () => + { + await action(); + return true; + }, + CancellationToken.None) + .GetAwaiter() + .GetResult(); + } + } + + public static TResult Run(Func function) + { + ArgumentNullException.ThrowIfNull(function); + + if (typeof(Task).IsAssignableFrom(typeof(TResult))) + { + throw new InvalidOperationException("Use the Func overload for asynchronous work."); + } + + lock (_sessionGate) + { + return _session.Value.Dispatch(function, CancellationToken.None).GetAwaiter().GetResult(); + } + } +} diff --git a/GenLauncherGO.Tests/Testing/StaTestRunnerTests.cs b/GenLauncherGO.Tests/Testing/StaTestRunnerTests.cs new file mode 100644 index 00000000..43b52aec --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StaTestRunnerTests.cs @@ -0,0 +1,71 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Threading; + +namespace GenLauncherGO.Tests.Testing; + +[Collection("Avalonia")] +public sealed class StaTestRunnerTests +{ + [Fact] + public void Run_Action_ExecutesOnAvaloniaUiThread() + { + bool ranOnDispatcherThread = false; + + StaTestRunner.Run(() => { ranOnDispatcherThread = Dispatcher.UIThread.CheckAccess(); }); + + ranOnDispatcherThread.Should().BeTrue(); + } + + [Fact] + public void Run_AsyncAction_PreservesDispatcherAffinityAcrossAwait() + { + bool keptDispatcherAffinity = false; + + StaTestRunner.Run(async () => + { + int dispatcherThreadId = Environment.CurrentManagedThreadId; + var dispatchedThreadId = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + Dispatcher.UIThread.Post(() => + dispatchedThreadId.TrySetResult(Environment.CurrentManagedThreadId)); + + int callbackThreadId = await dispatchedThreadId.Task; + + keptDispatcherAffinity = + Dispatcher.UIThread.CheckAccess() && + callbackThreadId == dispatcherThreadId && + Environment.CurrentManagedThreadId == dispatcherThreadId; + }); + + keptDispatcherAffinity.Should().BeTrue(); + } + + [Fact] + public void Run_ActionThrows_PropagatesException() + { + static void FailingAction() + { + throw new InvalidOperationException("dispatched failure"); + } + + Action run = () => StaTestRunner.Run(FailingAction); + + run.Should().Throw().WithMessage("dispatched failure"); + } + + [Fact] + public void Run_AsyncActionThrowsAfterAwait_PropagatesException() + { + static async Task FailingActionAsync() + { + await Task.Yield(); + + throw new InvalidOperationException("dispatched failure after await"); + } + + Action run = () => StaTestRunner.Run(FailingActionAsync); + + run.Should().Throw().WithMessage("dispatched failure after await"); + } +} diff --git a/GenLauncherGO.Tests/Testing/StubDownloadFileMetadataReader.cs b/GenLauncherGO.Tests/Testing/StubDownloadFileMetadataReader.cs new file mode 100644 index 00000000..75d30069 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StubDownloadFileMetadataReader.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Answers download metadata requests and counts them, which is how a test sees whether a size lookup was +/// avoided, retried, or resolved from cache rather than asked for again. +/// +internal sealed class StubDownloadFileMetadataReader : IDownloadFileMetadataReader +{ + private readonly Func>? _handler; + + public StubDownloadFileMetadataReader( + Func>? handler = null) + { + _handler = handler; + } + + /// + /// Answers every request with the same metadata, for a test whose subject is what happens after the lookup + /// rather than the lookup itself. + /// + public StubDownloadFileMetadataReader(string fileName, long? totalBytes) + : this((downloadUri, _) => Task.FromResult( + new DownloadFileMetadata(downloadUri, fileName, totalBytes))) + { + } + + public int RequestCount { get; private set; } + + public Task ReadMetadataAsync(Uri downloadUri, CancellationToken cancellationToken) + { + RequestCount++; + return _handler?.Invoke(downloadUri, cancellationToken) ?? + Task.FromException( + new InvalidOperationException($"No metadata result was configured for '{downloadUri}'.")); + } +} diff --git a/GenLauncherGO.Tests/Testing/StubFileHashService.cs b/GenLauncherGO.Tests/Testing/StubFileHashService.cs new file mode 100644 index 00000000..710b1b33 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StubFileHashService.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Contracts; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class StubFileHashService : IFileHashService +{ + /// + /// The hash a manifest fixture declares when the file on disk is meant to match it. + /// + public const string MatchingHash = "0123456789ABCDEF0123456789ABCDEF"; + + /// + /// A hash that differs from , for the corrupted-download cases. + /// + public const string MismatchedHash = "FEDCBA9876543210FEDCBA9876543210"; + + public Func HashForPath { get; init; } = _ => MatchingHash; + + public Task ComputeMd5HashAsync(string filePath, CancellationToken cancellationToken) + { + return Task.FromResult(HashForPath(filePath)); + } +} diff --git a/GenLauncherGO.Tests/Testing/StubLauncherDialogService.cs b/GenLauncherGO.Tests/Testing/StubLauncherDialogService.cs new file mode 100644 index 00000000..aeb4847d --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StubLauncherDialogService.cs @@ -0,0 +1,27 @@ +using Avalonia.Controls; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; + +namespace GenLauncherGO.Tests.Testing; + +internal static class StubLauncherDialogService +{ + /// + /// Creates a dialog service that answers every warning confirmation with . + /// + /// + /// A substitute rather than a hand-written fake because the callers assert the request, continue text, and + /// owner window a workflow raised the dialog with, none of which + /// records. + /// + public static ILauncherDialogService AnsweringWarningConfirmations(bool confirmed) + { + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowWarningConfirmationAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(confirmed); + return dialogService; + } +} diff --git a/GenLauncherGO.Tests/Testing/StubLauncherFilePicker.cs b/GenLauncherGO.Tests/Testing/StubLauncherFilePicker.cs new file mode 100644 index 00000000..d11ac2d0 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StubLauncherFilePicker.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Controls; +using GenLauncherGO.UI.Features.Launcher.Contracts; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class StubLauncherFilePicker : ILauncherFilePicker +{ + public string? GameInstallationFolderResult { get; init; } + + public IReadOnlyList ManualPackageFilesResult { get; init; } = []; + + public string? ModificationImageFileResult { get; init; } + + public string? GameExecutableFileResult { get; init; } + + public Exception? GameInstallationFolderFailure { get; init; } + + /// + /// The folder each browse started from, which is how a test sees that the picker opened where the user was + /// already working rather than at an unrelated default. + /// + public List RequestedInitialDirectories { get; } = []; + + public Task PickGameInstallationFolderAsync( + Window owner, + string? initialDirectory) + { + RequestedInitialDirectories.Add(initialDirectory); + return GameInstallationFolderFailure is null + ? Task.FromResult(GameInstallationFolderResult) + : Task.FromException(GameInstallationFolderFailure); + } + + public Task> PickManualPackageFilesAsync(Window owner) + { + return Task.FromResult(ManualPackageFilesResult); + } + + public Task PickModificationImageFileAsync( + Window owner, + string imageFilterLabel) + { + return Task.FromResult(ModificationImageFileResult); + } + + public Task PickGameExecutableFileAsync(Window owner, string gameDirectory) + { + return Task.FromResult(GameExecutableFileResult); + } +} diff --git a/GenLauncherGO.Tests/Testing/StubRemoteYamlDocumentReader.cs b/GenLauncherGO.Tests/Testing/StubRemoteYamlDocumentReader.cs new file mode 100644 index 00000000..4f144039 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StubRemoteYamlDocumentReader.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Remote.Contracts; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class StubRemoteYamlDocumentReader : IRemoteYamlDocumentReader +{ + private readonly Dictionary<(Type DocumentType, Uri DocumentUri), int> _readCounts = []; + + private readonly Dictionary< + (Type DocumentType, Uri DocumentUri), + Func>> _readHandlers = []; + + private readonly Lock _sync = new(); + + public Task ReadYamlAsync(Uri documentUri, CancellationToken cancellationToken) + { + Func> handler; + int callIndex; + + lock (_sync) + { + (Type DocumentType, Uri DocumentUri) key = (typeof(T), documentUri); + callIndex = _readCounts.GetValueOrDefault(key) + 1; + _readCounts[key] = callIndex; + + if (!_readHandlers.TryGetValue(key, out handler!)) + { + throw new InvalidOperationException( + $"No YAML response was configured for {typeof(T).Name} at {documentUri}."); + } + } + + return ReadConfiguredAsync(handler, callIndex, cancellationToken); + } + + public void SetResult(Uri documentUri, T result) + { + SetHandler(documentUri, (_, _) => Task.FromResult(result)); + } + + public void SetException(Uri documentUri, Exception exception) + { + SetHandler(documentUri, (_, _) => Task.FromException(exception)); + } + + public void SetHandler( + Uri documentUri, + Func> handler) + { + ArgumentNullException.ThrowIfNull(documentUri); + ArgumentNullException.ThrowIfNull(handler); + + lock (_sync) + { + _readHandlers[(typeof(T), documentUri)] = async (callIndex, cancellationToken) => + (await handler(callIndex, cancellationToken).ConfigureAwait(false))!; + } + } + + public int GetReadCount(Uri documentUri) + { + lock (_sync) + { + return _readCounts.GetValueOrDefault((typeof(T), documentUri)); + } + } + + public int GetReadCount() + { + lock (_sync) + { + int count = 0; + + foreach (((Type DocumentType, Uri DocumentUri) key, int value) in _readCounts) + { + if (key.DocumentType == typeof(T)) + { + count += value; + } + } + + return count; + } + } + + private static async Task ReadConfiguredAsync( + Func> handler, + int callIndex, + CancellationToken cancellationToken) + { + object result = await handler(callIndex, cancellationToken).ConfigureAwait(false); + return (T)result; + } +} diff --git a/GenLauncherGO.Tests/Testing/SymbolicLinkFactAttribute.cs b/GenLauncherGO.Tests/Testing/SymbolicLinkFactAttribute.cs new file mode 100644 index 00000000..6fa05f0d --- /dev/null +++ b/GenLauncherGO.Tests/Testing/SymbolicLinkFactAttribute.cs @@ -0,0 +1,18 @@ +using System.Runtime.CompilerServices; + +namespace GenLauncherGO.Tests.Testing; + +public sealed class SymbolicLinkFactAttribute : FactAttribute +{ + public SymbolicLinkFactAttribute( + [CallerFilePath] string? sourceFilePath = null, + [CallerLineNumber] int sourceLineNumber = -1) + : base(sourceFilePath, sourceLineNumber) + { + if (!SymbolicLinkTestSupport.IsRequired && + !SymbolicLinkTestSupport.IsSupported) + { + Skip = SymbolicLinkTestSupport.UnsupportedReason; + } + } +} diff --git a/GenLauncherGO.Tests/Testing/SymbolicLinkTestSupport.cs b/GenLauncherGO.Tests/Testing/SymbolicLinkTestSupport.cs new file mode 100644 index 00000000..4ccc38ff --- /dev/null +++ b/GenLauncherGO.Tests/Testing/SymbolicLinkTestSupport.cs @@ -0,0 +1,102 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Tests.Testing; + +internal static class SymbolicLinkTestSupport +{ + private const string RequiredEnvironmentVariable = "GENLAUNCHERGO_REQUIRE_SYMBOLIC_LINK_TESTS"; + + internal const string UnsupportedReason = + "These safety tests need a file that is itself a reparse point, which only a symbolic link provides. " + + "A junction is a directory, so File.Exists short-circuits before production reaches its reparse check, " + + "and every test that can use one already does. Enable Windows Developer Mode or run with symbolic-link " + + "privileges to cover the remaining cases."; + + private static readonly Lazy _symbolicLinkSupport = new(ProbeSymbolicLinkSupport); + + internal static bool IsRequired => + bool.TryParse( + Environment.GetEnvironmentVariable(RequiredEnvironmentVariable), + out bool isRequired) && + isRequired; + + internal static bool IsSupported => _symbolicLinkSupport.Value; + + public static void CreateFileLink(string linkPath, string targetPath) + { + CreateLink( + () => File.CreateSymbolicLink(linkPath, targetPath), + "file"); + } + + private static void CreateLink(Action createLink, string linkKind) + { + try + { + createLink(); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + throw new InvalidOperationException( + $"Could not create the {linkKind} symbolic link required by this safety test. " + + "CI requires symbolic-link tests to execute.", + exception); + } + } + + private static bool ProbeSymbolicLinkSupport() + { + string testRoot = Path.Combine( + Path.GetTempPath(), + "GenLauncherGO.Tests", + $"SymbolicLinkProbe-{Guid.NewGuid():N}"); + string fileTarget = Path.Combine(testRoot, "FileTarget.txt"); + string fileLink = Path.Combine(testRoot, "FileLink.txt"); + + try + { + Directory.CreateDirectory(testRoot); + File.WriteAllText(fileTarget, "target"); + File.CreateSymbolicLink(fileLink, fileTarget); + + return File.GetAttributes(fileLink).HasFlag(FileAttributes.ReparsePoint); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + finally + { + TryDeleteFileLink(fileLink); + TryDeleteProbeRoot(testRoot); + } + } + + private static void TryDeleteFileLink(string fileLink) + { + try + { + File.Delete(fileLink); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + } + } + + private static void TryDeleteProbeRoot(string testRoot) + { + try + { + if (Directory.Exists(testRoot)) + { + Directory.Delete(testRoot, true); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + } + } +} diff --git a/GenLauncherGO.Tests/Testing/TestDirectory.cs b/GenLauncherGO.Tests/Testing/TestDirectory.cs new file mode 100644 index 00000000..22a3149d --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestDirectory.cs @@ -0,0 +1,114 @@ +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 +{ + private const string TestRootFolderName = "GenLauncherGO.Tests"; + + public TestDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + TestRootFolderName, + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + DeleteTree(Path); + } + } + + /// + /// Deletes a tree that may contain reparse points. throws + /// when it encounters a junction, so links are removed explicitly + /// and never followed into their target. + /// + private static void DeleteTree(string path) + { + foreach (string entryPath in Directory.EnumerateFileSystemEntries(path)) + { + FileAttributes attributes = File.GetAttributes(entryPath); + bool isDirectory = (attributes & FileAttributes.Directory) != 0; + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + if (isDirectory) + { + Directory.Delete(entryPath); + } + else + { + File.Delete(entryPath); + } + + continue; + } + + if (isDirectory) + { + DeleteTree(entryPath); + continue; + } + + File.Delete(entryPath); + } + + Directory.Delete(path); + } + + public string GetPath(string relativePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + + if (System.IO.Path.IsPathFullyQualified(relativePath)) + { + throw new ArgumentException("The test path must be relative.", nameof(relativePath)); + } + + string fullPath = System.IO.Path.GetFullPath(System.IO.Path.Combine(Path, relativePath)); + string resolvedRelativePath = System.IO.Path.GetRelativePath(Path, fullPath); + string parentPrefix = $"..{System.IO.Path.DirectorySeparatorChar}"; + + if (resolvedRelativePath.Equals("..", StringComparison.Ordinal) || + resolvedRelativePath.StartsWith(parentPrefix, StringComparison.Ordinal) || + System.IO.Path.IsPathFullyQualified(resolvedRelativePath)) + { + throw new ArgumentException("The test path must stay inside the owned directory.", nameof(relativePath)); + } + + return fullPath; + } + + public string CreateDirectory(string relativePath) + { + string directoryPath = GetPath(relativePath); + Directory.CreateDirectory(directoryPath); + return directoryPath; + } + + public string CreateFile(string relativePath, string contents = "") + { + ArgumentNullException.ThrowIfNull(contents); + + string filePath = GetPath(relativePath); + string? parentDirectory = System.IO.Path.GetDirectoryName(filePath); + if (parentDirectory != null) + { + Directory.CreateDirectory(parentDirectory); + } + + File.WriteAllText(filePath, contents); + return filePath; + } +} diff --git a/GenLauncherGO.Tests/Testing/TestImageFile.cs b/GenLauncherGO.Tests/Testing/TestImageFile.cs new file mode 100644 index 00000000..6ce5f6f7 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestImageFile.cs @@ -0,0 +1,28 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Supplies the smallest real image the launcher's bitmap loading accepts. +/// +internal static class TestImageFile +{ + private const string OnePixelPngBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + + /// + /// A one-pixel PNG, so a test asserting on decoded pixel size has a known answer. + /// + private static byte[] OnePixelPng { get; } = Convert.FromBase64String(OnePixelPngBase64); + + public static string Write(TestDirectory directory, string fileName) + { + ArgumentNullException.ThrowIfNull(directory); + + string filePath = directory.GetPath(fileName); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllBytes(filePath, OnePixelPng); + return filePath; + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLaunchContentIntegrityCoordinator.cs b/GenLauncherGO.Tests/Testing/TestLaunchContentIntegrityCoordinator.cs new file mode 100644 index 00000000..bf31f5fa --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLaunchContentIntegrityCoordinator.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Shared.Localization; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Builds the integrity coordinator with a resolution service that reports nothing to fix, which is the +/// arrangement every test that is not about integrity needs. +/// +internal static class TestLaunchContentIntegrityCoordinator +{ + public static LaunchContentIntegrityCoordinator Create( + ILaunchContentIntegrityResolutionService? resolutionService = null, + ILauncherContentCatalog? catalog = null, + LauncherRuntimePathContext? runtimePaths = null, + LauncherPackageActivityService? packageActivityService = null, + ILauncherDialogService? dialogService = null, + ILauncherStringLocalizer? stringLocalizer = null) + { + return new LaunchContentIntegrityCoordinator( + resolutionService ?? CreateNoIssueResolutionService(), + catalog ?? new FakeLauncherContentCatalog(), + runtimePaths ?? TestLauncherPaths.CreateRuntimePathContext(TestLauncherPaths.Create()), + packageActivityService ?? new LauncherPackageActivityService(), + stringLocalizer ?? FakeStringLocalizer.Create(TestLocalizedStrings.Integrity), + dialogService ?? Substitute.For(), + NullLogger.Instance); + } + + private static ILaunchContentIntegrityResolutionService CreateNoIssueResolutionService() + { + ILaunchContentIntegrityResolutionService resolutionService = + Substitute.For(); + resolutionService.VerifyAsync( + Arg.Any(), + Arg.Any()) + .Returns(new LaunchContentIntegrityVerificationResult( + new ContentIntegrityReport(Array.Empty()), + Array.Empty())); + return resolutionService; + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherContent.cs b/GenLauncherGO.Tests/Testing/TestLauncherContent.cs new file mode 100644 index 00000000..6594b505 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherContent.cs @@ -0,0 +1,188 @@ +using System; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Builds launcher content fixtures. This is the single place a test describes a version, so a change to the +/// domain model lands once instead of in every test file that used to spell out its own factory. +/// +internal static class TestLauncherContent +{ + /// + /// The S3 endpoint every managed-package fixture publishes from. + /// + public const string S3Host = "https://s3.example.test"; + + /// + /// The bucket every managed-package fixture publishes into. + /// + public const string S3Bucket = "mods"; + + public static LauncherContentVersion Version( + string name = "ShockWave", + string version = "1.0", + ModificationType type = ModificationType.Mod, + string parentContentName = "", + bool installed = false, + bool isSelected = false, + ContentSourceKind sourceKind = ContentSourceKind.UnknownLegacy, + string simpleDownloadLink = "", + bool deprecated = false, + bool downloadSuspended = false, + double suspendedProgressPercentage = 0, + LauncherContentTheme? theme = null) + { + return new LauncherContentVersion(new LauncherContentInstallation + { + Installed = installed, + IsSelected = isSelected, + ContentSourceKind = sourceKind, + DownloadSuspended = downloadSuspended, + SuspendedProgressPercentage = suspendedProgressPercentage + }) + { + ModificationType = type, + Name = name, + Version = version, + ParentContentName = parentContentName, + SimpleDownloadLink = simpleDownloadLink, + Deprecated = deprecated, + Theme = theme + }; + } + + /// + /// Builds a version whose metadata resolves to . + /// + public static LauncherContentVersion S3Version( + string name = "ShockWave", + string version = "1.0", + ModificationType type = ModificationType.Mod, + string parentContentName = "", + bool installed = false, + bool isSelected = false, + string? s3FolderName = null) + { + return new LauncherContentVersion(new LauncherContentInstallation + { + Installed = installed, + IsSelected = isSelected, + ContentSourceKind = ContentSourceKind.ManagedS3 + }) + { + ModificationType = type, + Name = name, + Version = version, + ParentContentName = parentContentName, + S3HostLink = S3Host, + S3BucketName = S3Bucket, + S3FolderName = s3FolderName ?? $"{name}/{version}" + }; + } + + /// + /// Materializes a content card through a real round trip, so the card a test + /// asserts against was assembled by the production merge policy rather than by the test. + /// + public static LauncherContent From(params LauncherContentVersion[] versions) + { + ArgumentNullException.ThrowIfNull(versions); + + if (versions.Length == 0) + { + throw new ArgumentException("At least one version is required.", nameof(versions)); + } + + var data = new LauncherData(); + foreach (LauncherContentVersion version in versions) + { + data.AddOrUpdate(version); + } + + return data.FindContent(versions[0].ContentKey) ?? + throw new InvalidOperationException( + $"'{versions[0].DisplayName}' is not a content kind LauncherData stores."); + } + + public static CatalogBuilder Catalog() + { + return new CatalogBuilder(); + } + + /// + /// Fills a through the production catalog model. + /// + internal sealed class CatalogBuilder + { + private readonly FakeLauncherContentCatalog _catalog = new(); + + public CatalogBuilder WithMod( + string name, + string version = "1.0", + bool installed = true) + { + return Add(Version(name, version, ModificationType.Mod, installed: installed)); + } + + public CatalogBuilder WithPatch( + string parentContentName, + string name, + string version = "1.0", + bool installed = true) + { + return Add(Version( + name, + version, + ModificationType.Patch, + parentContentName, + installed)); + } + + public CatalogBuilder WithAddon( + string parentContentName, + string name, + string version = "1.0", + bool installed = true) + { + return Add(Version( + name, + version, + ModificationType.Addon, + parentContentName, + installed)); + } + + /// + /// Marks an already-added card and its versions as the user's selection. + /// + public CatalogBuilder Selected( + string name, + ModificationType type = ModificationType.Mod, + string parentContentName = "") + { + LauncherContent content = + _catalog.Data.FindContent(new LauncherContentKey(type, parentContentName, name, string.Empty)) ?? + throw new InvalidOperationException($"'{name}' was not added to the catalog."); + content.IsSelected = true; + foreach (LauncherContentVersion version in content.Versions) + { + version.Installation.IsSelected = true; + } + + return this; + } + + public FakeLauncherContentCatalog Build() + { + return _catalog; + } + + private CatalogBuilder Add(LauncherContentVersion version) + { + _catalog.Data.AddOrUpdate(version); + return this; + } + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherInstallations.cs b/GenLauncherGO.Tests/Testing/TestLauncherInstallations.cs new file mode 100644 index 00000000..823818c6 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherInstallations.cs @@ -0,0 +1,36 @@ +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.UI.Features.Launcher.Contracts; +using GenLauncherGO.UI.Features.Startup.ViewModels; +using GenLauncherGO.UI.Shared.Localization; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Builds the shared installation-path view model that first-run setup and launcher settings both edit. +/// +internal static class TestLauncherInstallations +{ + /// + /// The launcher root every installation test validates against. + /// + public static LauncherStoragePaths StoragePaths { get; } = new(@"C:\Launcher"); + + public static LauncherInstallationsViewModel CreateViewModel( + LauncherInstallations? installations = null, + IGameInstallationService? installationService = null, + ILauncherFilePicker? filePicker = null, + ILauncherHostEnvironmentService? hostEnvironmentService = null, + LauncherStoragePaths? storagePaths = null, + ILauncherStringLocalizer? stringLocalizer = null) + { + return new LauncherInstallationsViewModel( + installations ?? new LauncherInstallations(), + storagePaths ?? StoragePaths, + installationService ?? new FakeGameInstallationService(), + hostEnvironmentService ?? Substitute.For(), + filePicker ?? new StubLauncherFilePicker(), + stringLocalizer ?? new FakeStringLocalizer()); + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherLaunchCoordinator.cs b/GenLauncherGO.Tests/Testing/TestLauncherLaunchCoordinator.cs new file mode 100644 index 00000000..fbf98503 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherLaunchCoordinator.cs @@ -0,0 +1,87 @@ +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.Settings.Contracts; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Shared.Localization; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Testing; + +internal static class TestLauncherLaunchCoordinator +{ + public static LauncherLaunchCoordinator Create( + LauncherPackageActivityService? packageActivityService = null, + ILauncherPreferencesService? preferencesService = null, + ILauncherContentCatalog? catalog = null, + ILauncherStringLocalizer? stringLocalizer = null, + ILaunchPreparationService? preparationService = null, + IGameProcessLauncher? processLauncher = null, + ILauncherDialogService? dialogService = null, + ILaunchContentIntegrityResolutionService? integrityResolutionService = null, + LauncherRuntimePathContext? runtimePaths = null) + { + LauncherPackageActivityService resolvedPackageActivityService = + packageActivityService ?? new LauncherPackageActivityService(); + ILauncherPreferencesService resolvedPreferencesService = + preferencesService ?? new RecordingLauncherPreferencesService(new LauncherPreferences()); + ILauncherStringLocalizer resolvedStringLocalizer = + stringLocalizer ?? FakeStringLocalizer.Create(TestLocalizedStrings.Launch); + ILauncherDialogService resolvedDialogService = + dialogService ?? Substitute.For(); + ILaunchPreparationService resolvedPreparationService = + preparationService ?? CreateSuccessfulPreparationService(); + IGameProcessLauncher resolvedProcessLauncher = + processLauncher ?? CreateSuccessfulProcessLauncher(); + LauncherRuntimePathContext resolvedRuntimePaths = + runtimePaths ?? TestLauncherPaths.CreateRuntimePathContext(TestLauncherPaths.Create()); + + return new LauncherLaunchCoordinator( + resolvedPreferencesService, + resolvedPreparationService, + resolvedProcessLauncher, + TestLaunchContentIntegrityCoordinator.Create( + integrityResolutionService, + catalog, + resolvedRuntimePaths, + resolvedPackageActivityService, + resolvedDialogService, + resolvedStringLocalizer), + resolvedPackageActivityService, + resolvedRuntimePaths, + resolvedStringLocalizer, + resolvedDialogService, + NullLogger.Instance); + } + + private static ILaunchPreparationService CreateSuccessfulPreparationService() + { + ILaunchPreparationService preparationService = Substitute.For(); + preparationService.Prepare( + Arg.Any(), + Arg.Any()) + .Returns(true); + preparationService.Cleanup( + Arg.Any(), + Arg.Any()) + .Returns(true); + return preparationService; + } + + private static IGameProcessLauncher CreateSuccessfulProcessLauncher() + { + IGameProcessLauncher processLauncher = Substitute.For(); + processLauncher.StartAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult( + new CompletedGameProcessLaunchOperation(true, "generals.exe"))); + return processLauncher; + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherPaths.cs b/GenLauncherGO.Tests/Testing/TestLauncherPaths.cs new file mode 100644 index 00000000..52f7638e --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherPaths.cs @@ -0,0 +1,96 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Builds the launcher path sets tests need through the production authority, +/// so no test restates the owned folder layout. +/// +internal static class TestLauncherPaths +{ + private const string DefaultGameDirectory = @"C:\Games\ZeroHour"; + + private const string VirtualRootFolderName = "GenLauncherGO.Tests"; + + public static LauncherPaths Create( + string gameDirectory = DefaultGameDirectory, + SupportedGame game = SupportedGame.ZeroHour) + { + string fullGameDirectory = Path.GetFullPath(gameDirectory); + string gameParentDirectory = Path.GetDirectoryName(fullGameDirectory) + ?? throw new ArgumentException("The test game directory must have a parent.", + nameof(gameDirectory)); + string executableDirectory = Path.Combine( + gameParentDirectory, + Path.GetFileName(fullGameDirectory) + "-Launcher"); + return new LauncherStoragePaths(executableDirectory).CreateGamePaths(game, gameDirectory); + } + + public static LauncherPaths Create(TestDirectory directory, SupportedGame game = SupportedGame.ZeroHour) + { + ArgumentNullException.ThrowIfNull(directory); + + string gameDirectory = directory.CreateDirectory("Game"); + string executableDirectory = directory.CreateDirectory("Launcher"); + return CreateOwnedDirectories( + new LauncherStoragePaths(executableDirectory).CreateGamePaths(game, gameDirectory)); + } + + /// + /// Builds a path set below the test working directory without creating anything on disk, for tests that only + /// assert how paths are composed. + /// + public static LauncherPaths CreateVirtualRoot(string name, SupportedGame game = SupportedGame.ZeroHour) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + string root = Path.GetFullPath(Path.Combine(VirtualRootFolderName, name)); + return new LauncherStoragePaths(Path.Combine(root, "Launcher")) + .CreateGamePaths(game, Path.Combine(root, "Game")); + } + + /// + /// Builds both supported games from one storage root, which is the only arrangement a game switch is valid in. + /// + public static (LauncherRuntimePathContext RuntimePaths, LauncherPaths Generals, LauncherPaths ZeroHour) + CreateTwoGameRuntime(TestDirectory directory) + { + ArgumentNullException.ThrowIfNull(directory); + + var storagePaths = new LauncherStoragePaths(directory.CreateDirectory("Launcher")); + LauncherPaths generalsPaths = CreateOwnedDirectories(storagePaths.CreateGamePaths( + SupportedGame.Generals, + directory.CreateDirectory("Generals"))); + LauncherPaths zeroHourPaths = CreateOwnedDirectories(storagePaths.CreateGamePaths( + SupportedGame.ZeroHour, + directory.CreateDirectory("ZeroHour"))); + + return (new LauncherRuntimePathContext(storagePaths, zeroHourPaths), generalsPaths, zeroHourPaths); + } + + public static LauncherRuntimePathContext CreateRuntimePathContext(LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + string dataDirectory = Path.GetDirectoryName(paths.OwnedGameDataDirectory) + ?? throw new ArgumentException("The owned game data directory must have a parent.", + nameof(paths)); + string executableDirectory = Path.GetDirectoryName(dataDirectory) + ?? throw new ArgumentException( + "The shared launcher data directory must have a parent.", nameof(paths)); + return new LauncherRuntimePathContext(new LauncherStoragePaths(executableDirectory), paths); + } + + private static LauncherPaths CreateOwnedDirectories(LauncherPaths paths) + { + Directory.CreateDirectory(paths.OwnedGameDataDirectory); + Directory.CreateDirectory(paths.ImagesDirectory); + Directory.CreateDirectory(paths.ModsDirectory); + Directory.CreateDirectory(paths.TempDirectory); + Directory.CreateDirectory(paths.DeploymentDirectory); + Directory.CreateDirectory(paths.StateDirectory); + return paths; + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherRuntimeContext.cs b/GenLauncherGO.Tests/Testing/TestLauncherRuntimeContext.cs new file mode 100644 index 00000000..7f70c56b --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherRuntimeContext.cs @@ -0,0 +1,38 @@ +using GenLauncherGO.Core.Startup; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Shared.Themes; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Builds the runtime context on the single launcher layout owns. +/// +internal static class TestLauncherRuntimeContext +{ + internal const string LauncherVersion = "1.0.0-test"; + + public static LauncherRuntimeContext Create( + SupportedGame currentlyManagedGame = SupportedGame.ZeroHour, + ColorsInfo? colors = null, + bool connected = false) + { + return Create( + TestLauncherPaths.Create(game: currentlyManagedGame), + LauncherVersion, + colors, + connected); + } + + public static LauncherRuntimeContext Create( + LauncherPaths paths, + string version = LauncherVersion, + ColorsInfo? colors = null, + bool connected = false) + { + return new LauncherRuntimeContext(TestLauncherPaths.CreateRuntimePathContext(paths), version) + { + Colors = colors ?? TestLauncherTheme.Create(), + Connected = connected + }; + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherTheme.cs b/GenLauncherGO.Tests/Testing/TestLauncherTheme.cs new file mode 100644 index 00000000..334624a7 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherTheme.cs @@ -0,0 +1,38 @@ +using Avalonia.Media; +using GenLauncherGO.UI.Shared.Themes; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Builds a launcher theme for tests from the shipped Zero Hour palette. +/// +/// +/// Values are taken from the real preset rather than restated, so a test can never assert against a palette the +/// product does not actually produce. Override a slot only when a test needs to distinguish that one colour. +/// +internal static class TestLauncherTheme +{ + public static ColorsInfo Create(IImageBrush? backgroundImage = null, string border = "#00E3FF") + { + return new ColorsInfo( + borderColor: border, + inactiveBorderColor: "DarkGray", + inactiveBorder2: "#7A7DB0", + activeColor: "#BAFF0C", + darkFillColor: "#232977", + darkBackgroundColor: "#090502", + lightBackgroundColor: "#B3000000", + defaultTextColor: "White", + downloadTextColor: "#090502", + selectionStartColor: "#F21D2057", + selectionMiddleColor: "#F21D2057", + buttonSelectionColor: "#2534FF", + actionTextColor: "White", + headingTextColor: "White", + errorColor: "Red", + disabledTextColor: "#FF888888", + chromeBackgroundColor: "#FF000000", + scrimColor: "#66000000", + backgroundImage: backgroundImage); + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLocalizedStrings.cs b/GenLauncherGO.Tests/Testing/TestLocalizedStrings.cs new file mode 100644 index 00000000..c1adbe8b --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLocalizedStrings.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// The localized text tests assert against, grouped by the feature that needs it. +/// +/// +/// Each key carries one value across every set, so two tests can never assert different text for the same +/// resource. includes and includes +/// , because launching runs integrity review and the launcher window runs both. +/// stands alone: it is the executable manager's vocabulary, which shares only display +/// names with the rest. +/// +internal static class TestLocalizedStrings +{ + public static IReadOnlyDictionary Integrity { get; } = + new Dictionary(StringComparer.Ordinal) + { + ["AbsorbAndLaunch"] = "Absorb and launch", + ["CancelLaunch"] = "Cancel launch", + ["DownloadInProgress"] = "Downloaded {0} of {1}", + ["FixAbsorbAndLaunch"] = "Fix, absorb, and launch", + ["FixAndLaunch"] = "Fix and launch", + ["IntegrityAbsorbGroup"] = "Absorb", + ["IntegrityBlockGroup"] = "Blocked", + ["IntegrityBlockedDescription"] = "Remove blocked entries or restore content", + ["IntegrityCacheSuffix"] = " cache", + ["IntegrityDeleteGroup"] = "Delete", + ["IntegrityDialogTitle"] = "Integrity", + ["IntegrityIssueEmptyDirectoryLabel"] = "Empty folder", + ["IntegrityIssueGroupSummaryMultiple"] = "{0} changes", + ["IntegrityIssueGroupSummarySingle"] = "{0} change", + ["IntegrityIssueMissingFileLabel"] = "Missing", + ["IntegrityIssueModifiedFileLabel"] = "Modified", + ["IntegrityIssueUnexpectedFileLabel"] = "Added", + ["IntegrityIssueUnsafeLinkLabel"] = "Unsafe link", + ["IntegrityIssueUntrackedLabel"] = "Untracked", + ["IntegrityIssueVerificationErrorLabel"] = "Verification error", + ["IntegrityLegacyDescription"] = "Trust {0}", + ["IntegrityManagedDescription"] = "Repair {0}", + ["IntegrityManualDescription"] = "Absorb {0}", + ["IntegrityMixedDescription"] = "Repair {0}; absorb {1}", + ["IntegrityRedownloadGroup"] = "Redownload", + ["IntegrityRepairGroup"] = "Repair", + ["IntegrityRepeatedFailure"] = "Repeated failure", + ["IntegrityTrustGroup"] = "Trust", + ["LaunchVerificationRunning"] = "Verification running", + ["Preparing"] = "Preparing", + ["TrustAsManual"] = "Trust as manual", + ["UnpackingPreparing"] = "Unpacking" + }; + + public static IReadOnlyDictionary Launch { get; } = Merge( + Integrity, + new Dictionary(StringComparer.Ordinal) + { + ["AdvertisingCannotLaunch"] = "Advertisements cannot be launched.", + ["AdvertisingDonationAlerts"] = "Donate", + ["Cancel"] = "Cancel", + ["CancelDownloadAction"] = "Cancel Download", + ["Canceled"] = "Canceled", + ["Compatibility"] = "Compatibility", + ["Delete"] = "Delete", + ["DeploymentRecoveryFailed"] = "Deployment recovery failed.", + ["Deprecated"] = "{0} is deprecated", + ["Error"] = "Error: ", + ["FilesCorrupted"] = "Files corrupted", + ["GameRunning"] = "Game running", + ["GameSwitchBlockedDetails"] = "Wait for active work to finish.", + ["GameSwitchBlockedTitle"] = "Game switch blocked", + ["GameSwitchFailedDetails"] = "Could not switch: {0}", + ["GameSwitchFailedTitle"] = "Game switch failed", + ["Install"] = "Install", + ["InstallInProgress"] = "{0} install running", + ["InstallationPathUnavailable"] = "The installation path is unavailable.", + ["LaunchAborted"] = "Launch aborted", + ["LaunchCloseBlockedDetails"] = "Wait for launch.", + ["LaunchCloseBlockedTitle"] = "Launch in progress", + ["LatestVersion"] = "Latest version: ", + ["ModificationsWithUpdate"] = "Updates available", + ["NotInstalled"] = "{0} is not installed", + ["PackageActivityInProgress"] = "Package activity", + ["PackageActivityInProgressDetails"] = "Package activity details", + ["Pause"] = "Pause", + ["SettingsSaveFailedDetails"] = "Preferences could not be saved.", + ["Reinstall"] = "Reinstall", + ["RemoveFromList"] = "Remove from list", + ["Resume"] = "Resume", + ["UnexpectedErrorDetails"] = "Try again", + ["UnexpectedErrorTitle"] = "Unexpected error", + ["UninstalledUpdate"] = "{0} update is not installed", + ["Update"] = "Update", + ["UpToDate"] = "Up to date", + ["WorldBuilderRunning"] = "World Builder running" + }); + + public static IReadOnlyDictionary Launcher { get; } = Merge( + Launch, + new Dictionary(StringComparer.Ordinal) + { + ["AddAddonFromFiles"] = "Add addon for {0}", + ["AddPatchFromFiles"] = "Add patch for {0}", + ["Addons"] = "Add-ons for ", + ["CancelDownload"] = "Cancel download", + ["CancelDownloadDetails"] = "Cancel {0} and delete content downloaded so far", + ["ChangeToFullScreen"] = "Change to full screen", + ["ChangeToNormalStart"] = "Change to normal start", + ["ChangeToQuickStart"] = "Change to quick start", + ["ChangeToWindowed"] = "Change to windowed", + ["CloseAnyway"] = "Close anyway", + ["ClosePackageActivityDetails"] = "Close {0}?", + ["CommunityGameClientDisplayName"] = "TheSuperHackers", + ["CurrentVersion"] = "Current version: ", + ["Discord"] = "Discord", + ["ExecutableUnavailable"] = "Executable unavailable", + ["FinishProcess"] = "Finish process", + ["ForceQuitRunningProcess"] = "Force quit", + ["ForceQuitRunningProcessConfirmationDetails"] = "Force quit {0}?", + ["ForceQuitRunningProcessConfirmationTitle"] = "Force quit?", + ["GameIsStillRunning"] = "Game running", + ["GeneralsOnlineGameClientDisplayName"] = "GeneralsOnline", + ["GeneralsShortName"] = "Generals", + ["GenPatcherRecommendationMessage"] = + "It is strongly recommended to use GenPatcher when playing the retail game.", + ["GenPatcherRecommendationTitle"] = "GenPatcher recommendation", + ["Image"] = "Image", + ["ModDb"] = "Mod DB", + ["Patches"] = "Patches for ", + ["Remove"] = "Remove", + ["RemoveContent"] = "Remove content?", + ["RemoveContentDetails"] = "Remove {0}", + ["RemoveFromListConfirmation"] = "Remove from list?", + ["RemoveFromListDetails"] = "Remove {0} from list", + ["RestartBlockedActiveOperation"] = "Finish {0} before restarting.", + ["RestartBlockedTitle"] = "Restart unavailable", + ["RetailGameClientDisplayName"] = "Retail", + ["RetailWorldBuilder"] = "Retail", + ["RunningProcessCloseBlockedDetails"] = "Close process first", + ["RunningProcessCloseBlockedTitle"] = "Process running", + ["RunningProcessStatus"] = "Running {0}", + ["RunningProcessUnknown"] = "Unknown process", + ["SetImage"] = "Set image", + ["SuperHackersWorldBuilder"] = "TheSuperHackers", + ["ThankYou"] = "Thank you", + ["VisitGenPatcherDownloadPage"] = "Visit GenPatcher download page", + ["Yes"] = "Yes", + ["ZeroHourShortName"] = "Zero Hour" + }); + + public static IReadOnlyDictionary Settings { get; } = + new Dictionary(StringComparer.Ordinal) + { + ["AddExecutable"] = "Add executable", + ["BuiltInExecutables"] = "Built-in executables", + ["CommunityGameClientDisplayName"] = "TheSuperHackers", + ["CustomExecutables"] = "Custom executables", + ["EditExecutable"] = "Edit executable", + ["ExecutableDetailsRequired"] = "Details required", + ["ExecutableFileAlreadyExists"] = "Duplicate file", + ["ExecutableMustBeInGameRoot"] = "Must be in game root", + ["ExecutableNameAlreadyExists"] = "Duplicate name", + ["ExecutableUnavailable"] = "Unavailable", + ["GeneralsOnlineGameClientDisplayName"] = "GeneralsOnline", + ["ManageGameClients"] = "Manage game clients", + ["ManageWorldBuilders"] = "Manage World Builders", + ["RetailGameClientDisplayName"] = "Retail", + ["RetailWorldBuilder"] = "Retail", + ["SuperHackersWorldBuilder"] = "TheSuperHackers" + }; + + private static IReadOnlyDictionary Merge( + IReadOnlyDictionary baseSet, + Dictionary additions) + { + var merged = new Dictionary(baseSet, StringComparer.Ordinal); + foreach ((string key, string value) in additions) + { + merged[key] = value; + } + + return merged; + } +} diff --git a/GenLauncherGO.Tests/Testing/TestMainWindowViewModel.cs b/GenLauncherGO.Tests/Testing/TestMainWindowViewModel.cs new file mode 100644 index 00000000..489df7a6 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestMainWindowViewModel.cs @@ -0,0 +1,59 @@ +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Launcher.ViewModels; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Shared.Localization; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Owns the eleven collaborators the main window view model takes, so a test names only the ones it asserts on. +/// +internal static class TestMainWindowViewModel +{ + public static MainWindowViewModel Create( + ILauncherContentCatalog? catalog = null, + ILauncherPreferencesService? preferencesService = null, + LauncherRuntimeContext? runtimeContext = null, + IGameExecutableDiscoveryService? executableDiscovery = null, + LauncherPackageActivityService? packageActivityService = null, + LauncherLaunchCoordinator? launchCoordinator = null, + ILauncherStringLocalizer? stringLocalizer = null) + { + ILauncherContentCatalog resolvedCatalog = catalog ?? new FakeLauncherContentCatalog(); + ILauncherPreferencesService resolvedPreferencesService = + preferencesService ?? new RecordingLauncherPreferencesService(new LauncherPreferences()); + LauncherRuntimeContext resolvedRuntimeContext = runtimeContext ?? TestLauncherRuntimeContext.Create(); + ILauncherStringLocalizer resolvedStringLocalizer = + stringLocalizer ?? FakeStringLocalizer.Create(TestLocalizedStrings.Launcher); + LauncherPackageActivityService resolvedPackageActivityService = + packageActivityService ?? new LauncherPackageActivityService(); + + return new MainWindowViewModel( + resolvedPreferencesService, + new LauncherExecutableSelectionService( + executableDiscovery ?? Substitute.For(), + resolvedRuntimeContext, + resolvedPreferencesService, + resolvedStringLocalizer), + resolvedCatalog, + resolvedRuntimeContext, + resolvedStringLocalizer, + new ModificationImageSourceFactory(NullLogger.Instance), + Substitute.For(), + resolvedPackageActivityService, + NullLogger.Instance, + launchCoordinator ?? TestLauncherLaunchCoordinator.Create( + resolvedPackageActivityService, + resolvedPreferencesService, + resolvedCatalog, + resolvedStringLocalizer), + NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/Testing/TestModificationTile.cs b/GenLauncherGO.Tests/Testing/TestModificationTile.cs new file mode 100644 index 00000000..4b4c5f96 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestModificationTile.cs @@ -0,0 +1,32 @@ +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Shared.Localization; +using GenLauncherGO.UI.Shared.Themes; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Owns the collaborators a modification tile needs but no test is asserting about. +/// +internal static class TestModificationTile +{ + public static ModificationViewModel Create( + LauncherContent content, + ILauncherStringLocalizer? localizer = null, + LauncherPackageActivityService? activity = null, + ColorsInfo? colors = null, + IModificationImageFileService? imageFileService = null) + { + return new ModificationViewModel( + content, + new ModificationImageSourceFactory(NullLogger.Instance), + TestLauncherRuntimeContext.Create(colors: colors), + imageFileService ?? Substitute.For(), + localizer ?? new FakeStringLocalizer(), + activity ?? new LauncherPackageActivityService(), + NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/Testing/TestPackageDownload.cs b/GenLauncherGO.Tests/Testing/TestPackageDownload.cs new file mode 100644 index 00000000..7e189b9e --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestPackageDownload.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.UI.Features.Integrity; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Starts package downloads that hold a chosen state, so a test can drive the rest of the launcher around one. +/// +internal static class TestPackageDownload +{ + /// + /// Starts a transfer that only ends when its token is canceled and leaves it paused, matching a user who + /// paused a download and then went on to do something else with the launcher. + /// + public static Task StartPaused( + LauncherPackageActivityService packageActivityService) + { + ArgumentNullException.ThrowIfNull(packageActivityService); + + object owner = new(); + packageActivityService.TryStartDownload( + owner, + "Paused download", + (_, _, cancellationToken) => WaitForCancellationAsync(cancellationToken), + () => { }, + _ => { }, + () => { }, + _ => { }, + out Task? lifecycle) + .Should() + .BeTrue(); + packageActivityService.TryToggleDownloadPause(owner, out bool paused).Should().BeTrue(); + paused.Should().BeTrue(); + + return lifecycle ?? throw new InvalidOperationException("Download lifecycle task was not created."); + } + + /// + /// Mirrors a transport that reports a canceled transfer as a terminal result rather than as a fault. + /// + public static async Task WaitForCancellationAsync(CancellationToken cancellationToken) + { + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + catch (OperationCanceledException) + { + return PackageDownloadResult.Canceled(); + } + + throw new InvalidOperationException("The cancellation delay unexpectedly completed."); + } +} diff --git a/GenLauncherGO.Tests/Testing/TestPackageUpdatePaths.cs b/GenLauncherGO.Tests/Testing/TestPackageUpdatePaths.cs new file mode 100644 index 00000000..d2368cc9 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestPackageUpdatePaths.cs @@ -0,0 +1,36 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Builds package ownership boundaries from the launcher's own directories, so no test restates where staging, +/// installed content, or recovery backups live. +/// +internal static class TestPackageUpdatePaths +{ + public static PackageUpdatePathSet Create( + LauncherPaths paths, + string temporaryRelativePath, + string installedRelativePath, + string? latestRelativePath = null) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentException.ThrowIfNullOrWhiteSpace(temporaryRelativePath); + ArgumentException.ThrowIfNullOrWhiteSpace(installedRelativePath); + + return new PackageUpdatePathSet( + Owned(paths.PackagesDirectory, temporaryRelativePath), + Owned(paths.ModsDirectory, installedRelativePath), + Owned(paths.PackageBackupsDirectory, installedRelativePath), + latestRelativePath is null ? null : Owned(paths.ModsDirectory, latestRelativePath)); + } + + private static OwnedContentPath Owned(string ownerRoot, string relativePath) + { + return new OwnedContentPath(ownerRoot, Path.Combine(ownerRoot, relativePath)); + } +} diff --git a/GenLauncherGO.Tests/Testing/TestTimeouts.cs b/GenLauncherGO.Tests/Testing/TestTimeouts.cs new file mode 100644 index 00000000..f95f117e --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestTimeouts.cs @@ -0,0 +1,15 @@ +using System; + +namespace GenLauncherGO.Tests.Testing; + +internal static class TestTimeouts +{ + /// + /// How long a test waits for asynchronous work before failing it. + /// + /// + /// Long enough to absorb scheduling jitter on a loaded agent, short enough that a genuine deadlock fails the + /// run instead of hanging it. Shared so the budget is one edit rather than one per await. + /// + public static readonly TimeSpan Wait = TimeSpan.FromSeconds(5); +} diff --git a/GenLauncherGO.Tests/UI/Features/Dialogs/Services/AvaloniaLauncherDialogServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Dialogs/Services/AvaloniaLauncherDialogServiceTests.cs new file mode 100644 index 00000000..fa9abc65 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Dialogs/Services/AvaloniaLauncherDialogServiceTests.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Threading; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Dialogs.Services; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Mods.Views; +using GenLauncherGO.UI.Shared.Localization; + +namespace GenLauncherGO.Tests.UI.Features.Dialogs.Services; + +[Collection("Avalonia")] +public sealed class AvaloniaLauncherDialogServiceTests +{ + [Fact] + public void ShowModificationSelectionAsync_AcceptedSelectionReturnsName() + { + StaTestRunner.Run(async () => + { + AvaloniaLauncherDialogService service = CreateService(); + + string? selectedName = await OwnedDialogTestHost.RunAsync( + dialog => dialog.ViewModel.AcceptCommand.Execute(null), + owner => service.ShowModificationSelectionAsync(new[] { "Contra" }, owner)); + + selectedName.Should().Be("Contra"); + }); + } + + [Fact] + public void ShowModificationSelectionAsync_CanceledDialog_StopsPendingMetadataWork() + { + StaTestRunner.Run(async () => + { + bool observedCancellation = false; + FakeLauncherContentCatalog catalog = new() + { + MetadataHandler = async (_, cancellationToken) => + { + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + throw new InvalidOperationException("The cancellation delay unexpectedly completed."); + } + finally + { + observedCancellation = cancellationToken.IsCancellationRequested; + } + } + }; + AvaloniaLauncherDialogService service = CreateService(catalog: catalog); + + string? selectedName = await OwnedDialogTestHost.RunAsync( + dialog => dialog.ViewModel.CancelCommand.Execute(null), + owner => service.ShowModificationSelectionAsync(new[] { "Contra" }, owner)); + + selectedName.Should().BeNull(); + observedCancellation.Should().BeTrue(); + }); + } + + [Fact] + public void ShowIntegrityReviewAsync_ConfirmedResolutionReturnsTrue() + { + StaTestRunner.Run(async () => + { + AvaloniaLauncherDialogService service = CreateService(); + + bool confirmed = await OwnedDialogTestHost.RunAsync( + dialog => dialog.ViewModel.ConfirmResolutionCommand.Execute(null), + owner => service.ShowIntegrityReviewAsync( + new ContentIntegrityReport(Array.Empty()), + owner)); + + confirmed.Should().BeTrue(); + }); + } + + [Fact] + public void ShowIntegrityReviewAsync_CanceledReviewReturnsFalse() + { + StaTestRunner.Run(async () => + { + AvaloniaLauncherDialogService service = CreateService(); + + bool confirmed = await OwnedDialogTestHost.RunAsync( + dialog => dialog.ViewModel.CancelCommand.Execute(null), + owner => service.ShowIntegrityReviewAsync( + new ContentIntegrityReport(Array.Empty()), + owner)); + + confirmed.Should().BeFalse(); + }); + } + + [Fact] + public void ShowManualModificationImportAsync_AcceptedDetailsReturnImportResult() + { + StaTestRunner.Run(async () => + { + AvaloniaLauncherDialogService service = CreateService(); + + ManualModificationDialogResult? result = + await OwnedDialogTestHost.RunAsync( + dialog => + { + dialog.ViewModel.ModificationName = "Patch Pack"; + dialog.ViewModel.Version = "1.2"; + dialog.ViewModel.AcceptCommand.Execute(null); + }, + owner => service.ShowManualModificationImportAsync( + new[] { @"C:\Packages\patch.zip" }, + owner)); + + result.Should().NotBeNull(); + result!.ModificationName.Should().Be("Patch Pack"); + result.Version.Should().Be("1.2"); + }); + } + + [Fact] + public void ShowManualModificationImportAsync_CanceledImportReturnsNull() + { + StaTestRunner.Run(async () => + { + AvaloniaLauncherDialogService service = CreateService(); + + ManualModificationDialogResult? result = + await OwnedDialogTestHost.RunAsync( + dialog => dialog.ViewModel.CancelCommand.Execute(null), + owner => service.ShowManualModificationImportAsync( + new[] { @"C:\Packages\patch.zip" }, + owner)); + + result.Should().BeNull(); + }); + } + + [Fact] + public void ShowWarningConfirmationAsync_PreservesCustomTextAndDetailFontSize() + { + StaTestRunner.Run(async () => + { + double observedFontSize = 0; + string? observedContinueText = null; + string? observedCancelText = null; + bool observedWarningIcon = false; + AvaloniaLauncherDialogService service = CreateService(new FakeStringLocalizer( + new Dictionary + { + ["Continue"] = "Continue", + ["Cancel"] = "Cancel" + })); + LauncherInfoDialogRequest request = new( + "Unsafe operation", + "This operation changes managed files.", + 12.5, + "Go back"); + + bool confirmed = await OwnedDialogTestHost.RunAsync( + dialog => + { + TextBlock detailMessage = + dialog.FindControl("DetailMessageText") ?? + throw new InvalidOperationException("The detail message control was not created."); + Button continueButton = + dialog.FindControl + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GenLauncherGO.UI/Features/Launcher/Views/MainWindow.axaml.cs b/GenLauncherGO.UI/Features/Launcher/Views/MainWindow.axaml.cs new file mode 100644 index 00000000..760cfb9a --- /dev/null +++ b/GenLauncherGO.UI/Features/Launcher/Views/MainWindow.axaml.cs @@ -0,0 +1,613 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using GenLauncherGO.Core.Launching; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.UI.Features.Launcher.Models; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Launcher.Support; +using GenLauncherGO.UI.Features.Launcher.ViewModels; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Shared.Controls; +using GenLauncherGO.UI.Shared.Errors; + +namespace GenLauncherGO.UI.Features.Launcher.Views; + +/// +/// Displays the main launcher UI for managing modifications and starting the selected game client. +/// +internal partial class MainWindow : Window +{ + private readonly HashSet> _activeWindowOperations = []; + private readonly LauncherWindowListController _contentController = null!; + private readonly LauncherDragDropController _dragDropController = null!; + private readonly IUiExceptionBoundary _exceptionBoundary = null!; + private readonly LauncherContentActionCoordinator _contentActionCoordinator = null!; + private readonly MainWindowViewModel _viewModel = null!; + private readonly LauncherWindowContext _windowContext = null!; + private readonly LauncherWindowWorkflowCoordinator _workflowCoordinator = null!; + private bool _closeApproved; + private bool _closePreparationInProgress; + private CancellationTokenSource _windowLifetime = new(); + + public MainWindow() + { + InitializeComponent(); + LauncherWindowScaling.Attach(this); + } + + public MainWindow( + MainWindowViewModel viewModel, + LauncherDragDropController dragDropController, + LauncherRuntimeContext runtimeContext, + LauncherWindowWorkflowCoordinator workflowCoordinator, + LauncherContentActionCoordinator contentActionCoordinator, + IUiExceptionBoundary exceptionBoundary) + : this() + { + _viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel)); + _dragDropController = dragDropController ?? throw new ArgumentNullException(nameof(dragDropController)); + ArgumentNullException.ThrowIfNull(runtimeContext); + _workflowCoordinator = workflowCoordinator ?? throw new ArgumentNullException(nameof(workflowCoordinator)); + _contentActionCoordinator = contentActionCoordinator ?? + throw new ArgumentNullException(nameof(contentActionCoordinator)); + _exceptionBoundary = exceptionBoundary ?? throw new ArgumentNullException(nameof(exceptionBoundary)); + + DataContext = _viewModel; + _contentController = new LauncherWindowListController( + _viewModel, + runtimeContext, + ModsList, + PatchesList, + AddonsList); + _windowContext = new LauncherWindowContext(_viewModel, _contentController, this); + Closing += MainWindow_ClosingAsync; + Opened += MainWindow_OpenedAsync; + Activated += MainWindow_Activated; + Deactivated += MainWindow_Deactivated; + ModsList.AddHandler( + PointerPressedEvent, + ModsList_PointerPressed, + RoutingStrategies.Tunnel); + ModsList.AddHandler( + PointerMovedEvent, + ModsList_PointerMoved, + RoutingStrategies.Tunnel); + ModsList.AddHandler( + PointerReleasedEvent, + ModsList_PointerReleased, + RoutingStrategies.Tunnel); + ModsList.PointerCaptureLost += ModsList_PointerCaptureLost; + PatchesList.AddHandler( + PointerPressedEvent, + PatchesList_PointerPressed, + RoutingStrategies.Tunnel); + PatchesList.AddHandler( + PointerReleasedEvent, + PatchesList_PointerReleased, + RoutingStrategies.Tunnel); + _viewModel.PropertyChanged += ViewModel_PropertyChangedAsync; + + _viewModel.Initialize(); + _contentController.Initialize(); + UpdateContentViewVisibility(); + } + + private async void MainWindow_OpenedAsync(object? sender, EventArgs eventArgs) + { + _contentController.RestoreModsListVerticalOffset(); + await ShowRetailClientRecommendationIfNeededAsync(); + } + + private void MainWindow_Activated(object? sender, EventArgs eventArgs) + { + _viewModel.RefreshGameClientOptions(); + _viewModel.RefreshWorldBuilderOptions(); + } + + private void MainWindow_Deactivated(object? sender, EventArgs eventArgs) + { + CancelContentPointerGesture(); + } + + private void ModsList_PointerPressed(object? sender, PointerPressedEventArgs eventArgs) + { + _dragDropController.CapturePointerGesture( + ModsList, + this, + eventArgs, + true); + } + + private void ModsList_PointerMoved(object? sender, PointerEventArgs eventArgs) + { + if (_dragDropController.HandlePointerMove(ModsList, eventArgs)) + { + UpdateDragPreview(eventArgs.GetPosition(DragLayer)); + } + } + + private void ModsList_PointerReleased(object? sender, PointerReleasedEventArgs eventArgs) + { + CompleteContentPointerGesture(ModsList, eventArgs); + } + + private void ModsList_PointerCaptureLost(object? sender, PointerCaptureLostEventArgs eventArgs) + { + CancelContentPointerGesture(); + } + + private void PatchesList_PointerPressed(object? sender, PointerPressedEventArgs eventArgs) + { + _dragDropController.CapturePointerGesture( + PatchesList, + this, + eventArgs, + false); + } + + private void PatchesList_PointerReleased(object? sender, PointerReleasedEventArgs eventArgs) + { + CompleteContentPointerGesture(PatchesList, eventArgs); + } + + private void CompleteContentPointerGesture( + ListBox contentList, + PointerReleasedEventArgs eventArgs) + { + bool moved = _dragDropController.TryCompletePointerGesture( + contentList, + eventArgs, + out bool selectionCleared, + out int sourceIndex, + out int targetIndex); + if (moved) + { + _viewModel.MoveModInList(sourceIndex, targetIndex); + } + + if (moved || selectionCleared) + { + eventArgs.Handled = true; + } + + HideDragPreview(); + } + + private void TileContextMenu_Opened(object? sender, RoutedEventArgs eventArgs) + { + CancelContentPointerGesture(); + } + + private void CancelContentPointerGesture() + { + _dragDropController?.CancelPointerGesture(); + HideDragPreview(); + } + + private void UpdateDragPreview(Point pointerPosition) + { + if (_dragDropController.DraggedModification is not { } modification) + { + HideDragPreview(); + return; + } + + const double PreviewWidth = 360; + const double PreviewHeight = 72; + const double PointerOffset = 18; + double layerWidth = DragLayer.Bounds.Width; + double layerHeight = DragLayer.Bounds.Height; + double horizontalPosition = pointerPosition.X + PointerOffset; + if (horizontalPosition + PreviewWidth > layerWidth) + { + horizontalPosition = pointerPosition.X - PreviewWidth - PointerOffset; + } + + ModificationDragPreview.DataContext = modification; + Canvas.SetLeft( + ModificationDragPreview, + Math.Clamp(horizontalPosition, 0, Math.Max(0, layerWidth - PreviewWidth))); + Canvas.SetTop( + ModificationDragPreview, + Math.Clamp( + pointerPosition.Y - PreviewHeight / 2, + 0, + Math.Max(0, layerHeight - PreviewHeight))); + ModificationDragPreview.IsVisible = true; + } + + private void HideDragPreview() + { + ModificationDragPreview.IsVisible = false; + ModificationDragPreview.DataContext = null; + } + + private async void ModsList_SelectionChangedAsync(object? sender, SelectionChangedEventArgs eventArgs) + { + await ExecuteWindowOperationAsync( + "changing the selected modification", + () => _contentController.HandleModsListSelectionChangedAsync(eventArgs)); + } + + private async void ChildContentList_SelectionChangedAsync(object? sender, SelectionChangedEventArgs eventArgs) + { + await ExecuteSyncAsync( + "changing selected child content", + () => _contentController.HandleChildContentListSelectionChanged(eventArgs)); + } + + private async void VersionsList_SelectionChangedAsync(object? sender, SelectionChangedEventArgs eventArgs) + { + await ExecuteSyncAsync( + "changing a selected content version", + () => _contentController.HandleVersionsListSelectionChanged(sender!)); + } + + private async void MainWindow_ClosingAsync(object? sender, WindowClosingEventArgs eventArgs) + { + if (_closeApproved) + { + return; + } + + // Cancel the first close request so package cancellation and terminal cleanup finish before the + // Avalonia desktop lifetime disposes application services. + eventArgs.Cancel = true; + if (_closePreparationInProgress) + { + return; + } + + if (!await _workflowCoordinator.ConfirmCloseDuringActiveOperationsAsync(this)) + { + return; + } + + _closePreparationInProgress = true; + IsEnabled = false; + Task[] activeWindowOperations = _activeWindowOperations + .Where(operation => !operation.IsCompleted) + .ToArray(); + UiOperationOutcome outcome = await _exceptionBoundary.ExecuteAsync( + "preparing the launcher to close", + async () => + { + await Task.Yield(); + _windowLifetime.Cancel(); + try + { + await _workflowCoordinator.PrepareForCloseAsync(); + } + finally + { + await Task.WhenAll(activeWindowOperations); + } + + _contentController.SaveModsListVerticalOffset(); + _viewModel.SaveLauncherData(); + }, + this); + + if (outcome != UiOperationOutcome.Succeeded) + { + _windowLifetime.Dispose(); + _windowLifetime = new CancellationTokenSource(); + _closePreparationInProgress = false; + IsEnabled = true; + return; + } + + CancelContentPointerGesture(); + _viewModel.PropertyChanged -= ViewModel_PropertyChangedAsync; + _viewModel.Dispose(); + _windowLifetime.Dispose(); + _closeApproved = true; + Close(); + } + + private async void LaunchGame_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ExecuteWindowOperationAsync( + "launching the selected game", + () => _workflowCoordinator.LaunchAsync( + GameLaunchTargetKind.GameClient, + _windowContext, + _windowLifetime.Token)); + } + + private async void LaunchWorldBuilder_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ExecuteWindowOperationAsync( + "launching World Builder", + () => _workflowCoordinator.LaunchAsync( + GameLaunchTargetKind.WorldBuilder, + _windowContext, + _windowLifetime.Token)); + } + + private async void ToggleWindowedMode_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ExecuteSyncAsync( + "changing the windowed-mode preference", + () => _viewModel.ToggleGameArgument(LauncherGameArgumentService.WindowedArgument)); + } + + private async void ToggleQuickStart_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ExecuteSyncAsync( + "changing the quick-start preference", + () => _viewModel.ToggleGameArgument(LauncherGameArgumentService.QuickStartArgument)); + } + + private async void OpenOptions_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ExecuteWindowOperationAsync( + "opening launcher settings", + () => _workflowCoordinator.OpenOptionsAsync( + _windowContext, + _windowLifetime.Token)); + } + + private void Close_Click(object? sender, RoutedEventArgs eventArgs) + { + Close(); + } + + private async void ShowModifications_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ShowContentViewAsync(LauncherContentViewKind.Modifications, "showing the modifications view"); + } + + private async void ShowPatches_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ShowContentViewAsync(LauncherContentViewKind.Patches, "showing the patches view"); + } + + private async void ShowAddons_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ShowContentViewAsync(LauncherContentViewKind.Addons, "showing the add-ons view"); + } + + private async Task ShowContentViewAsync( + LauncherContentViewKind viewKind, + string operationContext) + { + await ExecuteWindowOperationAsync( + operationContext, + () => _viewModel.ShowContentViewAsync(viewKind, _windowLifetime.Token)); + } + + private async void AddRepositoryModification_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ExecuteWindowOperationAsync( + "adding a repository modification", + () => _contentActionCoordinator.AddRepositoryModificationAsync( + _windowContext, + _windowLifetime.Token)); + } + + private async void ImportManualModification_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ImportManualContentAsync(ModificationType.Mod); + } + + private async void ImportManualPatch_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ImportManualContentAsync(ModificationType.Patch); + } + + private async void ImportManualAddon_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ImportManualContentAsync(ModificationType.Addon); + } + + private async Task ImportManualContentAsync(ModificationType kind) + { + await ExecuteWindowOperationAsync( + "importing manual content", + () => _contentActionCoordinator.ImportManualContentAsync( + _windowContext, + kind, + _windowLifetime.Token)); + } + + private async void UpdateModification_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + if (GetModification(sender) is not { } modification) + { + return; + } + + await ExecuteWindowOperationAsync( + "updating launcher content", + () => _contentActionCoordinator.UpdateModificationAsync( + _windowContext, + modification)); + } + + private async void ChangeVersionImage_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + if (GetModification(sender) is not { } modification) + { + return; + } + + await ExecuteWindowOperationAsync( + "changing a modification image", + () => _contentActionCoordinator.ChangeVersionImageAsync( + _windowContext, + modification, + _windowLifetime.Token)); + } + + /// + /// Opens the tile link named by the invoking control's . + /// + private async void OpenTileLink_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + if (sender is not Control { Tag: LauncherTileLinkKind kind }) + { + return; + } + + await ApplyModificationActionAsync( + sender, + $"opening a {kind} tile link", + modification => _contentActionCoordinator.OpenTileLink(modification, kind)); + } + + private async void DeleteVersion_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + if (sender is Control { DataContext: ModificationVersionSelection versionSelection }) + { + await ExecuteWindowOperationAsync( + "deleting a content version", + () => _contentActionCoordinator.DeleteVersionAsync( + _windowContext, + versionSelection)); + } + } + + private async void DeleteModification_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + if (GetModification(sender) is not { } modification) + { + return; + } + + await ExecuteWindowOperationAsync( + "deleting launcher content", + () => _contentActionCoordinator.DeleteModificationAsync( + _windowContext, + modification)); + } + + private async void ForceQuitRunningProcess_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ExecuteWindowOperationAsync( + "force closing the launched process", + () => _workflowCoordinator.ForceCloseRunningProcessAsync(this)); + } + + private async Task ApplyModificationActionAsync( + object? sender, + string operationContext, + Action action) + { + ModificationViewModel? modification = GetModification(sender); + if (modification != null) + { + await ExecuteSyncAsync(operationContext, () => action(modification)); + } + } + + private static ModificationViewModel? GetModification(object? sender) + { + return (sender as Control)?.DataContext as ModificationViewModel; + } + + private async void ViewModel_PropertyChangedAsync(object? sender, PropertyChangedEventArgs eventArgs) + { + if (eventArgs.PropertyName == nameof(MainWindowViewModel.SelectedGameClientOption)) + { + if (IsVisible) + { + await ShowRetailClientRecommendationIfNeededAsync(); + } + + return; + } + + if (eventArgs.PropertyName == nameof(MainWindowViewModel.ActiveContentView)) + { + UpdateContentViewVisibility(); + return; + } + + if (eventArgs.PropertyName != nameof(MainWindowViewModel.ShouldHideLauncherWindow)) + { + return; + } + + if (_viewModel.ShouldHideLauncherWindow) + { + Hide(); + } + else if (!_closePreparationInProgress) + { + Show(); + } + } + + private Task ShowRetailClientRecommendationIfNeededAsync() + { + return ExecuteWindowOperationAsync( + "showing the retail client recommendation", + () => _workflowCoordinator.ShowRetailClientRecommendationIfNeededAsync( + _viewModel.SelectedGameClientOption, + this)); + } + + private void UpdateContentViewVisibility() + { + bool modificationsVisible = + _viewModel.ActiveContentView == LauncherContentViewKind.Modifications; + bool patchesVisible = + _viewModel.ActiveContentView == LauncherContentViewKind.Patches; + bool addonsVisible = + _viewModel.ActiveContentView == LauncherContentViewKind.Addons; + + ModsList.IsVisible = modificationsVisible; + PatchesList.IsVisible = patchesVisible; + AddonsList.IsVisible = addonsVisible; + ManualAddMod.IsVisible = modificationsVisible; + ManualAddPatch.IsVisible = patchesVisible; + ManualAddAddon.IsVisible = addonsVisible; + } + + private async Task ExecuteWindowOperationAsync( + string operationContext, + Func operation) + { + if (_closePreparationInProgress) + { + return; + } + + Task operationTask = _exceptionBoundary.ExecuteAsync( + operationContext, + operation, + this); + _activeWindowOperations.Add(operationTask); + try + { + await operationTask; + } + finally + { + _activeWindowOperations.Remove(operationTask); + } + } + + private async Task ExecuteSyncAsync(string operationContext, Action action) + { + await _exceptionBoundary.ExecuteAsync( + operationContext, + () => + { + action(); + return Task.CompletedTask; + }, + this); + } +} diff --git a/GenLauncherGO.UI/Features/Mods/ModificationImageSourceFactory.cs b/GenLauncherGO.UI/Features/Mods/ModificationImageSourceFactory.cs new file mode 100644 index 00000000..89ce346d --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ModificationImageSourceFactory.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Concurrent; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using GenLauncherGO.Core.Startup; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.UI.Features.Mods; + +/// +/// Creates Avalonia bitmaps for modification tiles without extracting generated image variants to disk. +/// +internal sealed class ModificationImageSourceFactory +{ + private const string DefaultImageResourceNamePrefix = "GenLauncherGO.UI.Features.Mods.Resources."; + + private readonly ConcurrentDictionary + _colorImageCache = new(StringComparer.OrdinalIgnoreCase); + + private readonly ConcurrentDictionary _grayscaleImageCache = + new(StringComparer.OrdinalIgnoreCase); + + private readonly ILogger _logger; + + public ModificationImageSourceFactory(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Loads the default unknown modification image for the currently managed game. + /// + public Bitmap LoadDefaultImage(SupportedGame supportedGame, bool grayscale) + { + string resourceName = DefaultImageResourceNamePrefix + (supportedGame == SupportedGame.ZeroHour + ? "UserAddedModBannerZeroHour.jpg" + : "UserAddedModBannerGenerals.jpg"); + + return LoadResourceImage(resourceName, grayscale); + } + + /// + /// Loads a modification image from disk and optionally converts it to grayscale. + /// + /// + /// This method reads the file into memory before returning so callers can safely replace or delete the source file + /// after a successful load. + /// + public Bitmap? LoadFileImage(string? path, bool grayscale) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + return null; + } + + FileInfo fileInfo = new(path); + string? cacheKey = null; + + try + { + cacheKey = CreateFileCacheKey(fileInfo); + Bitmap colorImage = GetOrLoadImage(cacheKey, () => DecodeFileImage(fileInfo.FullName)); + return grayscale ? GetOrCreateGrayscaleImage(cacheKey, colorImage) : colorImage; + } + catch (Exception exception) when (exception is IOException or NotSupportedException + or UnauthorizedAccessException) + { + if (cacheKey != null) + { + RemoveCachedImages(cacheKey); + } + + _logger.LogWarning(exception, "Failed to load modification image {ImageFileName}.", fileInfo.Name); + throw; + } + } + + /// + /// Creates a cache key that changes when a file is replaced or edited. + /// + private static string CreateFileCacheKey(FileInfo fileInfo) + { + fileInfo.Refresh(); + return string.Concat( + "file:", + fileInfo.FullName, + ":", + fileInfo.Length.ToString(CultureInfo.InvariantCulture), + ":", + fileInfo.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture)); + } + + private static Bitmap DecodeFileImage(string path) + { + using FileStream stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + return DecodeStreamImage(stream); + } + + private Bitmap LoadResourceImage(string resourceName, bool grayscale) + { + string cacheKey = "resource:" + resourceName; + + try + { + Bitmap colorImage = GetOrLoadImage(cacheKey, () => DecodeResourceImage(resourceName)); + return grayscale ? GetOrCreateGrayscaleImage(cacheKey, colorImage) : colorImage; + } + catch (Exception exception) when (exception is IOException or NotSupportedException) + { + RemoveCachedImages(cacheKey); + _logger.LogError(exception, "Failed to load modification image resource {ImageResourceName}.", + resourceName); + throw; + } + } + + private static Bitmap DecodeResourceImage(string resourceName) + { + Stream stream = typeof(ModificationImageSourceFactory).Assembly.GetManifestResourceStream(resourceName) + ?? throw new IOException("The modification image resource was not found."); + + using (stream) + { + return DecodeStreamImage(stream); + } + } + + private static Bitmap DecodeStreamImage(Stream stream) + { + return new Bitmap(stream); + } + + private Bitmap GetOrLoadImage(string cacheKey, Func imageFactory) + { + return _colorImageCache.GetOrAdd(cacheKey, _ => imageFactory()); + } + + private Bitmap GetOrCreateGrayscaleImage(string cacheKey, Bitmap source) + { + return _grayscaleImageCache.GetOrAdd(cacheKey, _ => CreateGrayscaleImage(source)); + } + + /// + /// Converts a decoded bitmap to grayscale while preserving its alpha channel. + /// + private static Bitmap CreateGrayscaleImage(Bitmap source) + { + WriteableBitmap grayscale = new( + source.PixelSize, + source.Dpi, + PixelFormat.Bgra8888, + AlphaFormat.Premul); + using (ILockedFramebuffer framebuffer = grayscale.Lock()) + { + source.CopyPixels(framebuffer); + + int redOffset; + int blueOffset; + if (framebuffer.Format == PixelFormat.Bgra8888) + { + redOffset = 2; + blueOffset = 0; + } + else if (framebuffer.Format == PixelFormat.Rgba8888) + { + redOffset = 0; + blueOffset = 2; + } + else + { + throw new NotSupportedException( + "The writable bitmap did not expose a supported 32-bit pixel format."); + } + + byte[] row = new byte[framebuffer.RowBytes]; + for (int y = 0; y < framebuffer.Size.Height; y++) + { + nint rowAddress = framebuffer.Address + checked(y * framebuffer.RowBytes); + Marshal.Copy(rowAddress, row, 0, row.Length); + for (int x = 0; x < framebuffer.Size.Width; x++) + { + int pixelOffset = x * 4; + int luminance = + row[pixelOffset + redOffset] * 77 + + row[pixelOffset + 1] * 150 + + row[pixelOffset + blueOffset] * 29; + byte gray = (byte)((luminance + 128) >> 8); + row[pixelOffset] = gray; + row[pixelOffset + 1] = gray; + row[pixelOffset + 2] = gray; + } + + Marshal.Copy(row, 0, rowAddress, row.Length); + } + } + + return grayscale; + } + + private void RemoveCachedImages(string cacheKey) + { + _colorImageCache.TryRemove(cacheKey, out _); + _grayscaleImageCache.TryRemove(cacheKey, out _); + } +} diff --git a/GenLauncherGO.UI/Features/Mods/ModificationVersionSelection.cs b/GenLauncherGO.UI/Features/Mods/ModificationVersionSelection.cs new file mode 100644 index 00000000..1adac6e0 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ModificationVersionSelection.cs @@ -0,0 +1,32 @@ +using System; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.UI.Features.Mods; + +internal sealed class ModificationVersionSelection +{ + public ModificationVersionSelection( + LauncherContentVersion selectedVersion, + ModificationViewModel modificationViewModel) + { + ArgumentNullException.ThrowIfNull(selectedVersion); + ArgumentNullException.ThrowIfNull(modificationViewModel); + if (!modificationViewModel.ContainerModification.Versions.Any(version => + version.ContentKey == selectedVersion.ContentKey)) + { + throw new ArgumentException( + "A selected version must belong to its modification tile.", + nameof(selectedVersion)); + } + + SelectedVersion = selectedVersion; + ModificationViewModel = modificationViewModel; + } + + public string VersionName => SelectedVersion.Version; + + public LauncherContentVersion SelectedVersion { get; } + + public ModificationViewModel ModificationViewModel { get; } +} diff --git a/GenLauncherGO.UI/Features/Mods/ModificationViewModel.cs b/GenLauncherGO.UI/Features/Mods/ModificationViewModel.cs new file mode 100644 index 00000000..35aba8c4 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ModificationViewModel.cs @@ -0,0 +1,803 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using Avalonia; +using Avalonia.Media; +using Avalonia.Media.Immutable; +using CommunityToolkit.Mvvm.ComponentModel; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Mods.ViewModels; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Shared.Localization; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.UI.Features.Mods; + +/// +/// Represents bindable UI state for a launcher modification tile. +/// +internal sealed class ModificationViewModel : ObservableObject, ILaunchContentIntegrityProgressTarget +{ + private readonly ModificationTileImageProvider _imageProvider; + + private readonly LauncherRuntimeContext _launcherContext; + + private readonly LauncherPackageActivityService _packageActivityService; + + private readonly ILauncherStringLocalizer _stringLocalizer; + + private bool _forwardedChildPackageActivityActive; + private bool _integrityProgressActive; + + public ModificationViewModel( + LauncherContent modification, + ModificationImageSourceFactory imageSourceFactory, + LauncherRuntimeContext launcherContext, + IModificationImageFileService modificationImageFileService, + ILauncherStringLocalizer stringLocalizer, + LauncherPackageActivityService packageActivityService, + ILogger logger) + { + _launcherContext = launcherContext ?? throw new ArgumentNullException(nameof(launcherContext)); + _stringLocalizer = stringLocalizer ?? throw new ArgumentNullException(nameof(stringLocalizer)); + _packageActivityService = packageActivityService ?? + throw new ArgumentNullException(nameof(packageActivityService)); + _imageProvider = new ModificationTileImageProvider( + imageSourceFactory, + launcherContext, + modificationImageFileService, + logger); + ContainerModification = modification ?? throw new ArgumentNullException(nameof(modification)); + ProgressBackground = ProgressBackgroundBrush; + ProgressForeground = ActiveProgressBrush; + ProgressBorderBrush = InactiveBrush; + ProgressTextForeground = DefaultTextBrush; + RefreshSelectedVersion(); + + UpdateButtonContent = _stringLocalizer["Update"]; + SupportButtonContent = _stringLocalizer["Donate"]; + ChangeLogButtonContent = _stringLocalizer["ChangelogOnly"]; + NetworkInfoButtonContent = _stringLocalizer["PlayOnline"]; + VersionActionContent = _stringLocalizer["RemoveFromList"]; + + InitializeVisualState(); + } + + public LauncherContent ContainerModification { get; } + + /// + /// Gets whether this card uses the compact patch and add-on presentation instead of the artwork presentation. + /// + public bool UsesCompactTileLayout => + ContainerModification.ModificationType is ModificationType.Addon or ModificationType.Patch; + + public LauncherContentVersion LatestVersion => ContainerModification.LatestVersion; + + public LauncherContentVersion? SelectedVersion { get; private set; } + + public string NameInfo => ContainerModification.Name; + + public string LatestVersionInfo => + ContainerModification.ModificationType == ModificationType.Advertising + ? LatestVersion.Version + : string.Concat(_stringLocalizer["LatestVersion"], LatestVersion.Version); + + public bool ReadyToRun { get; private set; } = true; + + public bool CanSetImage => + ContainerModification.ModificationType != ModificationType.Advertising && + LatestVersion.EffectiveContentSourceKind == ContentSourceKind.Manual; + + public bool CanOpenModDb => !string.IsNullOrEmpty(ContainerModification.LatestVersion.ModDBLink); + + public bool CanOpenDiscord => !string.IsNullOrEmpty(ContainerModification.LatestVersion.DiscordLink); + + public bool LocalMod => + ContainerModification.ModificationType == ModificationType.Mod && + !ContainerModification.Versions.Any(version => + version.EffectiveContentSourceKind.IsManagedRemote()); + + /// + /// Gets the palette this modification publishes for the launcher shell, or for none. + /// + public LauncherContentTheme? PublishedTheme => (SelectedVersion ?? LatestVersion).Theme; + + /// + /// Gets a value indicating whether package download, repair, or forwarded child activity is active. + /// + public bool HasActivePackageActivity => + _packageActivityService.GetActiveDownloadTask(this) is { IsCompleted: false } || + _integrityProgressActive || + _forwardedChildPackageActivityActive; + + public ObservableCollection VersionOptions { get; } = []; + + public ModificationVersionSelection? SelectedVersionOption + { + get; + set => SetProperty(ref field, value); + } + + public bool IsSelected + { + get; + set + { + if (SetProperty(ref field, value)) + { + OnPropertyChanged(nameof(IsSelectedOrAdvertising)); + } + } + } + + /// + /// Gets a value indicating whether selection-gated actions should be shown for this tile. + /// Advertising actions remain available without changing the selected game content. + /// + public bool IsSelectedOrAdvertising => + IsSelected || ContainerModification.ModificationType == ModificationType.Advertising; + + public IImage? ImageSource + { + get; + private set + { + if (SetProperty(ref field, value)) + { + OnPropertyChanged(nameof(HasImage)); + } + } + } + + public IImage? SelectedImageSource + { + get; + private set + { + if (SetProperty(ref field, value)) + { + OnPropertyChanged(nameof(HasImage)); + } + } + } + + public bool HasImage => ImageSource != null || SelectedImageSource != null; + + public bool IsVersionSelectorVisible + { + get; + private set => SetProperty(ref field, value); + } + + public bool IsVersionActionVisible + { + get; + private set => SetProperty(ref field, value); + } + + public bool IsDragAndDropVisible + { + get; + private set => SetProperty(ref field, value); + } + + public bool IsUpdateButtonVisible + { + get; + private set => SetProperty(ref field, value); + } = true; + + public bool IsSupportButtonVisible + { + get; + private set => SetProperty(ref field, value); + } = true; + + public bool IsNetworkInfoVisible + { + get; + private set => SetProperty(ref field, value); + } = true; + + public bool IsChangeLogVisible + { + get; + private set => SetProperty(ref field, value); + } = true; + + public Thickness ImageBorderThickness + { + get; + private set => SetProperty(ref field, value); + } = new(0); + + public IBrush ProgressBackground + { + get; + private set => SetProperty(ref field, value); + } + + public IBrush ProgressForeground + { + get; + private set => SetProperty(ref field, value); + } + + public IBrush ProgressBorderBrush + { + get; + private set => SetProperty(ref field, value); + } + + public IBrush ProgressTextForeground + { + get; + private set => SetProperty(ref field, value); + } + + public double ProgressValue + { + get; + private set => SetProperty(ref field, value); + } + + public string ProgressMessage + { + get; + private set => SetProperty(ref field, value); + } = string.Empty; + + public string UpdateButtonContent + { + get; + private set => SetProperty(ref field, value); + } + + public string SupportButtonContent + { + get; + private set => SetProperty(ref field, value); + } + + public string ChangeLogButtonContent + { + get; + private set => SetProperty(ref field, value); + } + + public string NetworkInfoButtonContent + { + get; + private set => SetProperty(ref field, value); + } + + public bool UpdateButtonEnabled + { + get; + private set => SetProperty(ref field, value); + } = true; + + public bool UpdateButtonBlinking + { + get; + private set => SetProperty(ref field, value); + } + + public bool SupportButtonBlinking + { + get; + private set => SetProperty(ref field, value); + } + + public bool IsVersionSelectorEnabled + { + get; + private set => SetProperty(ref field, value); + } = true; + + public string VersionActionContent + { + get; + private set => SetProperty(ref field, value); + } + + // Tiles push brushes into bindable properties instead of resolving DynamicResource, so they read the theme + // directly rather than through application resources. + private IBrush ActiveBrush => _launcherContext.Colors.GenLauncherActiveColor; + + private IBrush BorderBrush => _launcherContext.Colors.GenLauncherBorderColor; + + private IBrush DefaultTextBrush => _launcherContext.Colors.GenLauncherDefaultTextColor; + + private IBrush DownloadTextBrush => _launcherContext.Colors.GenLauncherDownloadTextColor; + + private IBrush InactiveBrush => _launcherContext.Colors.GenLauncherInactiveBorder; + + private IBrush ProgressBackgroundBrush => _launcherContext.Colors.GenLauncherDarkBackGround; + + private IBrush ActiveProgressBrush => + new ImmutableSolidColorBrush(_launcherContext.Colors.GenLauncherButtonSelectionColor); + + public LauncherContentVersion ActiveIntegrityVersion => SelectedVersion ?? LatestVersion; + + public void BeginIntegrityProgress(string message) + { + _integrityProgressActive = true; + ApplyPackageActivityVisualState(true); + ReportPackageProgress(message, 0); + } + + public void ReportIntegrityProgress(string message, int percentage) + { + ReportPackageProgress(message, percentage); + } + + public void CompleteIntegrityProgress() + { + _integrityProgressActive = false; + RefreshFromModelAndPresentation(); + ApplyPackageActivityVisualState(false); + OnPackageActivityChanged(); + } + + /// + /// Occurs when package download or repair activity state changes for this tile. + /// + public event EventHandler? PackageActivityChanged; + + /// + /// Loads the cached shell artwork published alongside . + /// + public IImageBrush? LoadPublishedThemeBackground() + { + return _imageProvider.LoadThemeBackground( + ContainerModification, + SelectedVersion ?? LatestVersion); + } + + public void RefreshFromModel() + { + RefreshSelectedVersion(); + OnStatePropertiesChanged(); + } + + public void SetDragAndDropMod() + { + IsDragAndDropVisible = true; + } + + public void RemoveDragAndDropMod() + { + IsDragAndDropVisible = false; + } + + public void RefreshPresentation() + { + ApplyPackageActivityVisualState(HasActivePackageActivity); + RefreshImages(); + } + + private void ApplyPackageActivityVisualState(bool isActive) + { + if (!isActive) + { + ProgressBackground = ProgressBackgroundBrush; + ProgressForeground = ActiveBrush; + ProgressBorderBrush = InactiveBrush; + ProgressTextForeground = DefaultTextBrush; + return; + } + + ProgressBackground = ActiveProgressBrush; + ProgressForeground = ActiveBrush; + ProgressBorderBrush = BorderBrush; + ProgressTextForeground = DownloadTextBrush; + } + + /// + /// Updates bindable tile state from current modification and download state. + /// + public void RefreshFromModelAndPresentation() + { + if (_packageActivityService.GetActiveDownloadTask(this) is not { IsCompleted: false }) + { + ResetDownloadVisuals(); + + RefreshFromModel(); + + if (ContainerModification.ModificationType != ModificationType.Advertising) + { + UpdateComboBox(); + SelectItemInComboBox(); + } + else + { + HideVersionSelector(); + } + } + + RefreshContentButtonAvailability(); + RefreshImages(); + } + + public void UpdateComboBox() + { + if (LatestVersion.Installation.Installed) + { + UpdateButtonContent = _stringLocalizer["UpToDate"]; + UpdateButtonEnabled = false; + UpdateButtonBlinking = false; + } + else + { + UpdateButtonContent = _stringLocalizer["Update"]; + UpdateButtonEnabled = true; + UpdateButtonBlinking = false; + } + + VersionOptions.Clear(); + foreach (LauncherContentVersion version in ContainerModification.Versions + .Where(modificationVersion => modificationVersion.Installation.Installed) + .OrderBy(modificationVersion => modificationVersion)) + { + VersionOptions.Add(new ModificationVersionSelection( + version, + this)); + } + } + + public void SelectItemInComboBox() + { + if (ContainerModification.Versions.Count == 0) + { + IsVersionSelectorEnabled = false; + SelectedVersionOption = null; + return; + } + + if (ContainerModification.Versions.Count == 1 && !LatestVersion.Installation.Installed) + { + ApplyInstallAvailableState(); + SelectedVersionOption = null; + return; + } + + IsVersionSelectorEnabled = true; + string versionString; + if (ReadyToRun) + { + versionString = SelectedVersion?.Version ?? string.Empty; + } + else + { + LauncherContentVersion selectedVersion = SelectLatestInstalledVersion(); + OnStatePropertiesChanged(); + versionString = selectedVersion.Version; + } + + SelectedVersionOption = VersionOptions.FirstOrDefault(selection => + string.Equals(selection.VersionName, versionString, StringComparison.Ordinal)); + } + + /// + /// Projects the lifecycle owner's single terminal package result onto this tile. + /// + public void CompletePackageActivityPresentation(PackageDownloadResult result) + { + ArgumentNullException.ThrowIfNull(result); + + // Captured before the reset below clears it, because a suspended download restores this exact position. + double progressAtCompletion = ProgressValue; + try + { + RefreshFromModelAndPresentation(); + ApplyTerminalDownloadResult(result, progressAtCompletion); + } + finally + { + OnPackageActivityChanged(); + } + } + + private void ApplyTerminalDownloadResult(PackageDownloadResult result, double progressAtCompletion) + { + switch (result.Status) + { + case PackageDownloadStatus.Succeeded: + ClearSuspendedDownload(); + ApplyPackageActivityVisualState(false); + break; + case PackageDownloadStatus.Canceled: + ClearSuspendedDownload(); + SetStatusMessage(_stringLocalizer["Canceled"]); + ApplyPackageActivityVisualState(false); + break; + case PackageDownloadStatus.Suspended: + RecordSuspendedDownload(progressAtCompletion); + break; + case PackageDownloadStatus.RecoverableFailure: + ShowDownloadFailure(result.Message); + break; + case PackageDownloadStatus.UnexpectedFailure: + ShowDownloadFailure(_stringLocalizer["UnexpectedErrorDetails"]); + break; + default: + throw new ArgumentOutOfRangeException( + nameof(result), + result.Status, + "Unknown package download status."); + } + } + + /// + /// Marks the version's partial content as kept and leaves the tile showing where the transfer stopped. + /// + private void RecordSuspendedDownload(double progressAtCompletion) + { + LauncherContentVersion version = SelectedVersion ?? LatestVersion; + version.Installation.DownloadSuspended = true; + version.Installation.SuspendedProgressPercentage = progressAtCompletion; + + ShowSuspendedDownload(progressAtCompletion); + } + + /// + /// Restores the paused progress a previous session left behind, so the tile reopens where it stopped. + /// + private void RestoreSuspendedDownload() + { + LauncherContentVersion version = SelectedVersion ?? LatestVersion; + if (version.Installation.DownloadSuspended) + { + ShowSuspendedDownload(version.Installation.SuspendedProgressPercentage); + } + } + + private void ShowSuspendedDownload(double progressPercentage) + { + ProgressValue = progressPercentage; + SetStatusMessage(_stringLocalizer["Paused"]); + UpdateButtonContent = _stringLocalizer["Resume"]; + ApplyPackageActivityVisualState(false); + IsUpdateButtonVisible = true; + SetUpdateButtonEnabled(true); + } + + /// + /// Drops the suspended marker once the version is no longer waiting to be resumed. + /// + private void ClearSuspendedDownload() + { + foreach (LauncherContentVersion version in ContainerModification.Versions) + { + version.Installation.DownloadSuspended = false; + version.Installation.SuspendedProgressPercentage = 0; + } + } + + private void ShowDownloadFailure(string message) + { + SetStatusMessage(string.Concat(_stringLocalizer["Error"], message)); + ApplyPackageActivityVisualState(false); + } + + /// + /// Prepares tile state for package download state. + /// + public void BeginPackageActivityPresentation() + { + // Starting a transfer settles whatever a previous session suspended, whether it resumes or restarts it. + ClearSuspendedDownload(); + UpdateButtonContent = _stringLocalizer["Pause"]; + UpdateButtonBlinking = false; + IsVersionSelectorEnabled = false; + ReadyToRun = false; + OnStatePropertiesChanged(); + + RefreshContentButtonAvailability(); + ApplyPackageActivityVisualState(true); + OnPackageActivityChanged(); + } + + /// + /// Updates the active download action to reflect whether the transfer is paused. + /// + public void SetPackageDownloadPaused(bool isPaused) + { + UpdateButtonContent = _stringLocalizer[isPaused ? "Resume" : "Pause"]; + } + + /// + /// Starts the one-time install notification for a newly added repository modification. + /// + public void NotifyInstallAvailable() + { + if (ContainerModification.ModificationType == ModificationType.Mod && + !LatestVersion.Installation.Installed) + { + UpdateButtonBlinking = true; + } + } + + public void SetStatusMessage(string message) + { + ProgressMessage = message; + } + + public void SetUpdateButtonEnabled(bool isEnabled) + { + UpdateButtonEnabled = isEnabled; + } + + public void SetSupportButtonBlinking(bool isBlinking) + { + SupportButtonBlinking = isBlinking; + } + + public void ReportPackageProgress(string message, int percentage) + { + ProgressMessage = message; + ProgressValue = percentage; + if (HasActivePackageActivity) + { + OnPackageActivityChanged(); + } + } + + private void ApplyInstallAvailableState() + { + IsVersionSelectorEnabled = false; + UpdateButtonContent = _stringLocalizer["Install"]; + ReadyToRun = false; + OnStatePropertiesChanged(); + } + + /// + /// Mirrors child-content package activity onto this parent tile. + /// + public void ReportForwardedChildPackageActivity(string message, int percentage) + { + if (_packageActivityService.GetActiveDownloadTask(this) is { IsCompleted: false } || + _integrityProgressActive) + { + return; + } + + _forwardedChildPackageActivityActive = true; + ApplyPackageActivityVisualState(true); + ReportPackageProgress(message, percentage); + } + + /// + /// Clears mirrored child-content package activity from this parent tile. + /// + public void CompleteForwardedChildPackageActivity() + { + if (!_forwardedChildPackageActivityActive) + { + return; + } + + _forwardedChildPackageActivityActive = false; + RefreshFromModelAndPresentation(); + ApplyPackageActivityVisualState(false); + OnPackageActivityChanged(); + } + + private void InitializeVisualState() + { + ResetDownloadVisuals(); + RefreshFromModelAndPresentation(); + RestoreSuspendedDownload(); + } + + private void ResetDownloadVisuals() + { + ProgressValue = 0; + ProgressMessage = string.Empty; + IsUpdateButtonVisible = true; + UpdateButtonContent = _stringLocalizer["Update"]; + SupportButtonContent = _stringLocalizer["Donate"]; + ChangeLogButtonContent = _stringLocalizer["ChangelogOnly"]; + NetworkInfoButtonContent = _stringLocalizer["PlayOnline"]; + ProgressTextForeground = DefaultTextBrush; + + if (ContainerModification.ModificationType != ModificationType.Advertising) + { + return; + } + + UpdateButtonContent = _stringLocalizer["AdvertisingDonationAlerts"]; + if (string.IsNullOrEmpty(ContainerModification.LatestVersion.SimpleDownloadLink)) + { + IsUpdateButtonVisible = false; + } + + ChangeLogButtonContent = _stringLocalizer["AdvertisingBoostyLink"]; + NetworkInfoButtonContent = _stringLocalizer["AdvertisingYouTubeRuLink"]; + } + + private void HideVersionSelector() + { + IsVersionSelectorVisible = false; + } + + private void RefreshContentButtonAvailability() + { + bool isAdvertising = ContainerModification.ModificationType == ModificationType.Advertising; + bool hasActiveDownload = + _packageActivityService.GetActiveDownloadTask(this) is { IsCompleted: false }; + IsVersionSelectorVisible = !isAdvertising && + ContainerModification.Installed && + !hasActiveDownload; + IsVersionActionVisible = !isAdvertising && + (hasActiveDownload || + (ContainerModification.ModificationType == ModificationType.Mod && + !ContainerModification.Installed)); + VersionActionContent = _stringLocalizer[ + hasActiveDownload + ? "CancelDownloadAction" + : "RemoveFromList"]; + IsChangeLogVisible = !string.IsNullOrEmpty(ContainerModification.LatestVersion.NewsLink); + IsNetworkInfoVisible = !string.IsNullOrEmpty(ContainerModification.LatestVersion.NetworkInfo); + IsSupportButtonVisible = !string.IsNullOrEmpty(ContainerModification.LatestVersion.SupportLink); + } + + private void RefreshImages() + { + ImageSource = _imageProvider.LoadGrayscaleImage( + ContainerModification, + LatestVersion, + LocalMod); + SelectedImageSource = _imageProvider.LoadColorImage( + ContainerModification, + LatestVersion, + LocalMod); + ImageBorderThickness = ImageSource == null && SelectedImageSource == null + ? new Thickness(0) + : new Thickness(2); + } + + private void RefreshSelectedVersion() + { + SelectedVersion = ContainerModification.GetSelectedVersion(); + SelectedVersion?.Installation.IsSelected = true; + } + + private LauncherContentVersion SelectLatestInstalledVersion() + { + SelectedVersion?.Installation.IsSelected = false; + + SelectedVersion = ContainerModification.LatestInstalledVersion ?? + throw new InvalidOperationException( + "An installed version is required before it can be selected."); + SelectedVersion.Installation.IsSelected = true; + ReadyToRun = true; + return SelectedVersion; + } + + private void OnStatePropertiesChanged() + { + OnPropertyChanged(nameof(ContainerModification)); + OnPropertyChanged(nameof(LatestVersion)); + OnPropertyChanged(nameof(SelectedVersion)); + OnPropertyChanged(nameof(NameInfo)); + OnPropertyChanged(nameof(LatestVersionInfo)); + OnPropertyChanged(nameof(ReadyToRun)); + OnPropertyChanged(nameof(CanSetImage)); + OnPropertyChanged(nameof(CanOpenModDb)); + OnPropertyChanged(nameof(CanOpenDiscord)); + OnPropertyChanged(nameof(LocalMod)); + OnPropertyChanged(nameof(ActiveIntegrityVersion)); + } + + private void OnPackageActivityChanged() + { + OnPropertyChanged(nameof(HasActivePackageActivity)); + PackageActivityChanged?.Invoke(this, EventArgs.Empty); + } +} diff --git a/GenLauncherNet/Images/uamG.jpg b/GenLauncherGO.UI/Features/Mods/Resources/UserAddedModBannerGenerals.jpg similarity index 100% rename from GenLauncherNet/Images/uamG.jpg rename to GenLauncherGO.UI/Features/Mods/Resources/UserAddedModBannerGenerals.jpg diff --git a/GenLauncherNet/Images/uamZH.jpg b/GenLauncherGO.UI/Features/Mods/Resources/UserAddedModBannerZeroHour.jpg similarity index 100% rename from GenLauncherNet/Images/uamZH.jpg rename to GenLauncherGO.UI/Features/Mods/Resources/UserAddedModBannerZeroHour.jpg diff --git a/GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationItemViewModel.cs b/GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationItemViewModel.cs new file mode 100644 index 00000000..a0c23557 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationItemViewModel.cs @@ -0,0 +1,50 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +/// +/// Presents one remotely available modification and its asynchronously resolved package metadata. +/// +internal sealed class AddModificationItemViewModel : ObservableObject +{ + private string _packageSizeText; + + public AddModificationItemViewModel(string name, string calculatingPackageSizeText) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + _packageSizeText = calculatingPackageSizeText ?? + throw new ArgumentNullException(nameof(calculatingPackageSizeText)); + } + + public string Name { get; } + + public string VersionText + { + get; + private set => SetProperty(ref field, value); + } = "\u2026"; + + public string PackageSizeText + { + get => _packageSizeText; + private set => SetProperty(ref _packageSizeText, value); + } + + public void SetMetadata(string versionText, string packageSizeText) + { + VersionText = string.IsNullOrWhiteSpace(versionText) ? "\u2014" : versionText; + PackageSizeText = packageSizeText; + } + + public void SetMetadataUnavailable(string packageSizeUnavailableText) + { + VersionText = "\u2014"; + PackageSizeText = packageSizeUnavailableText; + } + + public void SetPackageSize(string packageSizeText) + { + PackageSizeText = packageSizeText; + } +} diff --git a/GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationViewModel.cs b/GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationViewModel.cs new file mode 100644 index 00000000..503ff675 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationViewModel.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.UI.Shared.Formatting; +using GenLauncherGO.UI.Shared.Localization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +/// +/// Provides searchable selection state and cancellable remote metadata loading for repository modifications. +/// +internal sealed class AddModificationViewModel : ObservableObject, IDisposable +{ + private const int MaxConcurrentVersionRequests = 6; + + private const int MaxConcurrentPackageSizeRequests = 6; + + private readonly IReadOnlyList _allModifications; + + private readonly ILauncherContentCatalog _catalog; + + private readonly ILogger _logger; + + private readonly CancellationTokenSource _metadataCancellation = new(); + + private readonly IRemotePackageSizeResolver _packageSizeResolver; + + private readonly string _packageSizeUnavailableText; + + private bool _metadataLoadingStarted; + private AddModificationItemViewModel? _selectedModification; + + public AddModificationViewModel( + IReadOnlyList modificationNames, + ILauncherContentCatalog catalog, + IRemotePackageSizeResolver packageSizeResolver, + ILauncherStringLocalizer stringLocalizer, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(modificationNames); + _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + _packageSizeResolver = packageSizeResolver ?? throw new ArgumentNullException(nameof(packageSizeResolver)); + ArgumentNullException.ThrowIfNull(stringLocalizer); + _logger = logger ?? NullLogger.Instance; + _packageSizeUnavailableText = stringLocalizer["PackageSizeUnavailable"]; + + _allModifications = modificationNames + .Select(name => new AddModificationItemViewModel( + name, + stringLocalizer["CalculatingPackageSize"])) + .ToList(); + VisibleModifications = new ObservableCollection(_allModifications); + _selectedModification = VisibleModifications.FirstOrDefault(); + AcceptCommand = new RelayCommand(AcceptSelection, () => CanAccept); + CancelCommand = new RelayCommand(Cancel); + } + + public ObservableCollection VisibleModifications { get; } + + public string SearchText + { + get; + set + { + string newValue = value ?? string.Empty; + if (string.Equals(field, newValue, StringComparison.Ordinal)) + { + return; + } + + field = newValue; + OnPropertyChanged(); + ApplyFilter(); + } + } = string.Empty; + + public AddModificationItemViewModel? SelectedModification + { + get => _selectedModification; + set + { + if (ReferenceEquals(_selectedModification, value)) + { + return; + } + + _selectedModification = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(SelectedModificationName)); + OnPropertyChanged(nameof(CanAccept)); + AcceptCommand.NotifyCanExecuteChanged(); + } + } + + public string? SelectedModificationName => SelectedModification?.Name; + + public bool HasNoVisibleModifications => VisibleModifications.Count == 0; + + public bool CanAccept => + SelectedModification != null && VisibleModifications.Contains(SelectedModification); + + public IRelayCommand AcceptCommand { get; } + + public IRelayCommand CancelCommand { get; } + + public bool? DialogResult { get; private set; } + + public void Dispose() + { + _metadataCancellation.Dispose(); + } + + /// + /// Occurs when the view model requests that the owning dialog close. + /// + public event EventHandler? CloseRequested; + + /// + /// Loads version and package-size metadata with bounded concurrency until complete or canceled by dialog closure. + /// + public async Task LoadMetadataAsync() + { + if (_metadataLoadingStarted) + { + return; + } + + _metadataLoadingStarted = true; + using var versionGate = new SemaphoreSlim(MaxConcurrentVersionRequests); + using var packageSizeGate = new SemaphoreSlim(MaxConcurrentPackageSizeRequests); + try + { + await Task.WhenAll(_allModifications.Select(item => LoadMetadataAsync( + item, + versionGate, + packageSizeGate, + _metadataCancellation.Token))); + } + catch (OperationCanceledException) when (_metadataCancellation.IsCancellationRequested) + { + _logger.LogDebug("Canceled add-modification dialog metadata loading."); + } + } + + /// + /// Cancels remote metadata work when the dialog lifetime ends. + /// + public void CancelMetadataLoading() + { + if (!_metadataCancellation.IsCancellationRequested) + { + _metadataCancellation.Cancel(); + } + } + + private async Task LoadMetadataAsync( + AddModificationItemViewModel item, + SemaphoreSlim versionGate, + SemaphoreSlim packageSizeGate, + CancellationToken cancellationToken) + { + LauncherContentVersion version; + await versionGate.WaitAsync(cancellationToken); + try + { + try + { + version = await _catalog.GetRepositoryModificationMetadataAsync( + item.Name, + cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning( + "Failed to load repository metadata for modification {ModificationName}; failure type: {FailureType}.", + item.Name, + exception.GetType().Name); + item.SetMetadataUnavailable(_packageSizeUnavailableText); + return; + } + } + finally + { + versionGate.Release(); + } + + item.SetMetadata(version.Version, item.PackageSizeText); + await packageSizeGate.WaitAsync(cancellationToken); + try + { + long? totalBytes = await _packageSizeResolver.GetTotalBytesAsync(version, cancellationToken); + item.SetPackageSize(totalBytes.HasValue + ? ByteSizeFormatter.Format(totalBytes.Value) + : _packageSizeUnavailableText); + } + finally + { + packageSizeGate.Release(); + } + } + + private void ApplyFilter() + { + AddModificationItemViewModel? previousSelection = SelectedModification; + CompareInfo comparer = CultureInfo.CurrentCulture.CompareInfo; + var matchingItems = _allModifications + .Where(item => string.IsNullOrWhiteSpace(SearchText) || + comparer.IndexOf(item.Name, SearchText, CompareOptions.IgnoreCase) >= 0) + .ToList(); + + VisibleModifications.Clear(); + foreach (AddModificationItemViewModel item in matchingItems) + { + VisibleModifications.Add(item); + } + + SelectedModification = previousSelection != null && VisibleModifications.Contains(previousSelection) + ? previousSelection + : VisibleModifications.FirstOrDefault(); + OnPropertyChanged(nameof(HasNoVisibleModifications)); + OnPropertyChanged(nameof(CanAccept)); + AcceptCommand.NotifyCanExecuteChanged(); + } + + private void AcceptSelection() + { + if (!CanAccept) + { + return; + } + + CompleteDialog(true); + } + + private void Cancel() + { + CompleteDialog(false); + } + + private void CompleteDialog(bool accepted) + { + DialogResult = accepted; + CloseRequested?.Invoke(this, EventArgs.Empty); + } +} diff --git a/GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogKind.cs b/GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogKind.cs new file mode 100644 index 00000000..9e05af93 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogKind.cs @@ -0,0 +1,12 @@ +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +internal enum InfoDialogKind +{ + Info, + + InfoAction, + + Error, + + WarningConfirmation +} diff --git a/GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogViewModel.cs b/GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogViewModel.cs new file mode 100644 index 00000000..4726a8d1 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogViewModel.cs @@ -0,0 +1,122 @@ +using System; +using CommunityToolkit.Mvvm.Input; +using GenLauncherGO.UI.Features.Dialogs.Models; + +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +internal sealed class InfoDialogViewModel +{ + public InfoDialogViewModel( + LauncherInfoDialogRequest request, + InfoDialogKind kind, + string? continueText = null, + string? cancelText = null, + string? actionText = null) + { + ArgumentNullException.ThrowIfNull(request); + + MainMessage = request.MainMessage; + DetailMessage = request.DetailMessage; + DetailFontSize = request.DetailFontSize ?? 15D; + ContinueText = string.IsNullOrWhiteSpace(continueText) ? null : continueText; + CancelText = string.IsNullOrWhiteSpace(cancelText) ? "Cancel" : cancelText; + ActionText = string.IsNullOrWhiteSpace(actionText) ? null : actionText; + IsInfoAction = kind == InfoDialogKind.InfoAction; + OkCommand = new RelayCommand(Accept); + CancelCommand = new RelayCommand(Cancel); + ContinueCommand = OkCommand; + ActionCommand = new RelayCommand(CompleteAction); + CloseCommand = new RelayCommand(Close); + + IsWarningConfirmation = kind == InfoDialogKind.WarningConfirmation; + IsOkVisible = kind is InfoDialogKind.Info or InfoDialogKind.Error; + IsActionVisible = IsInfoAction && ActionText != null; + IsContinueVisible = kind == InfoDialogKind.WarningConfirmation; + IsCancelVisible = kind == InfoDialogKind.WarningConfirmation; + IsInfoIconVisible = kind is InfoDialogKind.Info or InfoDialogKind.InfoAction; + IsWarningIconVisible = kind == InfoDialogKind.WarningConfirmation; + IsErrorIconVisible = kind == InfoDialogKind.Error; + } + + public string MainMessage { get; } + + public string DetailMessage { get; } + + public double DetailFontSize { get; } + + public string? ContinueText { get; } + + public string CancelText { get; } + + public string? ActionText { get; } + + public bool IsOkVisible { get; } + + public bool IsContinueVisible { get; } + + public bool IsActionVisible { get; } + + public bool IsCancelVisible { get; } + + public bool IsInfoIconVisible { get; } + + public bool IsWarningIconVisible { get; } + + public bool IsErrorIconVisible { get; } + + public IRelayCommand OkCommand { get; } + + public IRelayCommand CancelCommand { get; } + + public IRelayCommand ContinueCommand { get; } + + public IRelayCommand ActionCommand { get; } + + public IRelayCommand CloseCommand { get; } + + /// + /// Gets the result requested by the dialog command. + /// + public bool? DialogResult { get; private set; } + + private bool IsWarningConfirmation { get; } + + private bool IsInfoAction { get; } + + /// + /// Occurs when the view model requests that the owning dialog close. + /// + public event EventHandler? CloseRequested; + + private void Accept() + { + CompleteDialog(!IsInfoAction); + } + + private void CompleteAction() + { + CompleteDialog(true); + } + + private void Cancel() + { + CompleteDialog(false); + } + + private void CompleteDialog(bool result) + { + DialogResult = result; + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + private void Close() + { + if (IsWarningConfirmation || IsInfoAction) + { + Cancel(); + return; + } + + Accept(); + } +} diff --git a/GenLauncherGO.UI/Features/Mods/ViewModels/ManualAddModificationViewModel.cs b/GenLauncherGO.UI/Features/Mods/ViewModels/ManualAddModificationViewModel.cs new file mode 100644 index 00000000..8f916cab --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/ManualAddModificationViewModel.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Shared.Localization; + +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +/// +/// Provides bindable manual import fields, validation, and actions for launcher content. +/// +internal sealed class ManualAddModificationViewModel : ObservableObject +{ + private const string MissingModificationNameKey = "EnterModName"; + + private const string MissingVersionKey = "EnterModVersion"; + + private const string UnsupportedCharactersKey = "NameAndVersionValidSymbols"; + + private const string VersionMissingNumberKey = "VersionMustContainNumbers"; + + private readonly ILauncherDialogService _dialogService; + + private readonly ILauncherStringLocalizer _stringLocalizer; + + private string _modificationName; + + public ManualAddModificationViewModel( + IReadOnlyList files, + ILauncherStringLocalizer stringLocalizer, + ILauncherDialogService dialogService) + { + ArgumentNullException.ThrowIfNull(files); + + _stringLocalizer = stringLocalizer ?? throw new ArgumentNullException(nameof(stringLocalizer)); + _dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService)); + _modificationName = InferModificationName(files); + AcceptCommand = new AsyncRelayCommand( + AcceptAsync, + () => CanAccept, + AsyncRelayCommandOptions.AllowConcurrentExecutions); + CancelCommand = new RelayCommand(Cancel); + } + + public string ModificationName + { + get => _modificationName; + set + { + string newValue = value ?? string.Empty; + if (string.Equals(_modificationName, newValue, StringComparison.Ordinal)) + { + return; + } + + _modificationName = newValue; + NotifyInputChanged(nameof(ModificationName), nameof(ModificationNameValidationMessage)); + } + } + + public string Version + { + get; + set + { + string newValue = value ?? string.Empty; + if (string.Equals(field, newValue, StringComparison.Ordinal)) + { + return; + } + + field = newValue; + NotifyInputChanged(nameof(Version), nameof(VersionValidationMessage)); + } + } = string.Empty; + + public string ModificationNameValidationMessage => + GetLocalizedValidationMessage(GetModificationNameValidationKey(ModificationName)); + + public string VersionValidationMessage => GetLocalizedValidationMessage(GetVersionValidationKey(Version)); + + public bool CanAccept => + GetModificationNameValidationKey(ModificationName) == null && + GetVersionValidationKey(Version) == null; + + public IAsyncRelayCommand AcceptCommand { get; } + + public IRelayCommand CancelCommand { get; } + + public ManualModificationDialogResult? ImportResult { get; private set; } + + public bool? DialogResult { get; private set; } + + /// + /// Occurs when the view model requests that the owning dialog close. + /// + public event EventHandler? CloseRequested; + + private async Task AcceptAsync() + { + string? validationKey = GetFirstValidationKey(); + if (validationKey != null) + { + await ShowValidationErrorAsync(validationKey); + return; + } + + ImportResult = new ManualModificationDialogResult( + ModificationName, + Version); + CompleteDialog(true); + } + + private void Cancel() + { + CompleteDialog(false); + } + + private void CompleteDialog(bool accepted) + { + DialogResult = accepted; + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + private static string InferModificationName(IReadOnlyList files) + { + string? firstFile = files.FirstOrDefault(file => !string.IsNullOrWhiteSpace(file)); + if (firstFile == null) + { + return string.Empty; + } + + string fileName = Path.GetFileNameWithoutExtension(firstFile); + if (string.IsNullOrWhiteSpace(fileName)) + { + return string.Empty; + } + + return NormalizeInferredName(fileName); + } + + private static string NormalizeInferredName(string fileName) + { + StringBuilder builder = new(fileName.Length); + bool previousWasSpace = false; + + foreach (char character in fileName) + { + if (IsSupportedFieldCharacter(character)) + { + builder.Append(character); + previousWasSpace = char.IsWhiteSpace(character); + continue; + } + + if (!previousWasSpace) + { + builder.Append(' '); + previousWasSpace = true; + } + } + + return builder.ToString().Trim(); + } + + private string? GetFirstValidationKey() + { + return GetModificationNameValidationKey(ModificationName) ?? GetVersionValidationKey(Version); + } + + private static string? GetModificationNameValidationKey(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return MissingModificationNameKey; + } + + return ContainsOnlySupportedFieldCharacters(value) + ? null + : UnsupportedCharactersKey; + } + + private static string? GetVersionValidationKey(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return MissingVersionKey; + } + + if (!value.Any(character => character is >= '0' and <= '9')) + { + return VersionMissingNumberKey; + } + + return ContainsOnlySupportedFieldCharacters(value) + ? null + : UnsupportedCharactersKey; + } + + private string GetLocalizedValidationMessage(string? validationKey) + { + return validationKey == null ? string.Empty : _stringLocalizer[validationKey]; + } + + private static bool ContainsOnlySupportedFieldCharacters(string value) + { + return value.All(IsSupportedFieldCharacter); + } + + private static bool IsSupportedFieldCharacter(char character) + { + return char.IsLetterOrDigit(character) || + character == '_' || + character == '.' || + character == '@' || + character == '-' || + character == ' '; + } + + private Task ShowValidationErrorAsync(string detailKey) + { + return _dialogService.ShowErrorAsync(new LauncherInfoDialogRequest( + _stringLocalizer["OperationAborted"], + _stringLocalizer[detailKey])); + } + + private void NotifyInputChanged(string fieldPropertyName, string validationPropertyName) + { + OnPropertyChanged(fieldPropertyName); + OnPropertyChanged(validationPropertyName); + OnPropertyChanged(nameof(CanAccept)); + AcceptCommand.NotifyCanExecuteChanged(); + } +} diff --git a/GenLauncherGO.UI/Features/Mods/ViewModels/ModificationTileImageProvider.cs b/GenLauncherGO.UI/Features/Mods/ViewModels/ModificationTileImageProvider.cs new file mode 100644 index 00000000..e6581074 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/ModificationTileImageProvider.cs @@ -0,0 +1,249 @@ +using System; +using System.Globalization; +using System.IO; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.UI.Features.Startup; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +/// +/// Loads Avalonia image sources for one modification tile. +/// +internal sealed class ModificationTileImageProvider +{ + private readonly ModificationImageSourceFactory _imageSourceFactory; + + private readonly LauncherRuntimeContext _launcherContext; + + private readonly ILogger _logger; + + private readonly IModificationImageFileService _modificationImageFileService; + + private int _advertisingImageIndex = -1; + + public ModificationTileImageProvider( + ModificationImageSourceFactory imageSourceFactory, + LauncherRuntimeContext launcherContext, + IModificationImageFileService modificationImageFileService, + ILogger logger) + { + _imageSourceFactory = imageSourceFactory ?? throw new ArgumentNullException(nameof(imageSourceFactory)); + _launcherContext = launcherContext ?? throw new ArgumentNullException(nameof(launcherContext)); + _modificationImageFileService = modificationImageFileService ?? + throw new ArgumentNullException(nameof(modificationImageFileService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Loads the cached shell artwork a version published with its palette, if it published one and it downloaded. + /// + /// + /// Returns when the modification declared no artwork or the download has not landed yet, + /// which the caller treats as "keep the active game's artwork" rather than as a failure to theme. + /// + public IImageBrush? LoadThemeBackground(LauncherContent modification, LauncherContentVersion version) + { + ArgumentNullException.ThrowIfNull(modification); + ArgumentNullException.ThrowIfNull(version); + + if (version.Theme is null || version.Theme.GenLauncherBackgroundImageLink.Length == 0) + { + return null; + } + + string? imagePath = _modificationImageFileService.FindExistingImageFilePath( + modification.ModificationType, + modification.Name, + LauncherContentTheme.ResolveBackgroundImageBaseName(version.Version)); + Bitmap? background = _imageSourceFactory.LoadFileImage(imagePath, false); + if (background is null) + { + return null; + } + + return (IImageBrush)new ImageBrush(background) + { + Stretch = Stretch.Fill + }.ToImmutable(); + } + + /// + /// Loads the grayscale image for a modification tile. + /// + public IImage? LoadGrayscaleImage( + LauncherContent modification, + LauncherContentVersion latestVersion, + bool localMod) + { + if (modification.ModificationType == ModificationType.Mod) + { + return LoadImage( + GetModificationImageFileName(modification, latestVersion, localMod), + modification.ModificationType, + modification.Name, + latestVersion.Version, + true, + true); + } + + if (modification.ModificationType == ModificationType.Advertising) + { + return LoadAdvertisingImage(modification); + } + + return null; + } + + /// + /// Loads the color image for a selected modification tile. + /// + public IImage? LoadColorImage( + LauncherContent modification, + LauncherContentVersion latestVersion, + bool localMod) + { + if (modification.ModificationType != ModificationType.Mod) + { + return null; + } + + return LoadImage( + GetModificationImageFileName(modification, latestVersion, localMod), + modification.ModificationType, + modification.Name, + latestVersion.Version, + false, + true); + } + + private IImage? LoadAdvertisingImage(LauncherContent modification) + { + int filesCount = _modificationImageFileService.CountImageFiles( + modification.ModificationType, + modification.Name); + if (filesCount <= 0) + { + return null; + } + + if (_advertisingImageIndex == -1) + { + var random = new Random(); + int value = random.Next(0, 30); + _advertisingImageIndex = value == 0 + ? random.Next(filesCount / 2, filesCount) + : random.Next(0, filesCount / 2); + } + + string imageBaseName = _advertisingImageIndex.ToString(CultureInfo.InvariantCulture); + string? imageFileName = _modificationImageFileService.FindExistingImageFilePath( + modification.ModificationType, + modification.Name, + imageBaseName); + + return LoadImage( + imageFileName, + modification.ModificationType, + modification.Name, + imageBaseName, + false, + false); + } + + private string? GetModificationImageFileName( + LauncherContent modification, + LauncherContentVersion latestVersion, + bool localMod) + { + string? imageFileName = _modificationImageFileService.FindExistingImageFilePath( + modification.ModificationType, + modification.Name, + latestVersion.Version); + + if (localMod && !_modificationImageFileService.ImageExists(imageFileName)) + { + return null; + } + + return imageFileName; + } + + /// + /// Loads a cached or default image for a modification tile. + /// + private IImage? LoadImage( + string? path, + ModificationType modificationType, + string modificationName, + string imageBaseName, + bool grayscale, + bool useDefaultWhenMissing) + { + try + { + Bitmap? image = _modificationImageFileService.ImageExists(path) + ? _imageSourceFactory.LoadFileImage(path, grayscale) + : null; + + if (image == null && useDefaultWhenMissing) + { + image = _imageSourceFactory.LoadDefaultImage( + _launcherContext.CurrentlyManagedGame, + grayscale); + } + + return image; + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Could not load modification image {ImageFileName}; attempting to remove the cached image.", + Path.GetFileName(path)); + + return TryRemoveInvalidImage( + path, + modificationType, + modificationName, + imageBaseName, + grayscale, + useDefaultWhenMissing); + } + } + + /// + /// Removes an invalid cached image and falls back to the default image when appropriate. + /// + private IImage? TryRemoveInvalidImage( + string? path, + ModificationType modificationType, + string modificationName, + string imageBaseName, + bool grayscale, + bool useDefaultWhenMissing) + { + try + { + _modificationImageFileService.TryDeleteImage( + modificationType, + modificationName, + imageBaseName); + + return useDefaultWhenMissing + ? _imageSourceFactory.LoadDefaultImage(_launcherContext.CurrentlyManagedGame, grayscale) + : null; + } + catch (Exception deleteException) + { + _logger.LogWarning( + deleteException, + "Could not remove invalid modification image {ImageFileName}.", + Path.GetFileName(path)); + return null; + } + } +} diff --git a/GenLauncherGO.UI/Features/Mods/Views/AddModificationWindow.axaml b/GenLauncherGO.UI/Features/Mods/Views/AddModificationWindow.axaml new file mode 100644 index 00000000..a34399b2 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/Views/AddModificationWindow.axaml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - -