From 5d5d15bc0579f6896c6a8547dae7b211e0739bb2 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 | 13 + .editorconfig | 160 + .github/ISSUE_TEMPLATE/bug_report.md | 28 - .github/ISSUE_TEMPLATE/feature_request.md | 20 - .github/workflows/ci.yml | 60 + .gitignore | 409 +- .mcp.json | 8 + AGENTS.md | 64 + Directory.Build.props | 15 + Directory.Build.targets | 22 + Directory.Packages.props | 28 + GenLauncher.sln | 31 - GenLauncherGO.Core/AGENTS.md | 9 + GenLauncherGO.Core/GenLauncherGO.Core.csproj | 5 + GenLauncherGO.Core/IO/LexicalPath.cs | 154 + .../Integrity/Models/ContentIntegrityIssue.cs | 11 + .../Models/ContentIntegrityReport.cs | 25 + .../Models/ContentIntegrityTarget.cs | 50 + .../Integrity/Models/ContentSourceKind.cs | 27 + .../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 | 143 + .../Launching/Models/GameClientExecutable.cs | 22 + .../Models/GameClientExecutableKind.cs | 8 + .../Launching/Models/GameLaunchRequest.cs | 57 + .../Launching/Models/GameLaunchTargetKind.cs | 8 + ...aunchContentIntegrityResolutionProgress.cs | 42 + ...LaunchContentIntegrityResolutionRequest.cs | 30 + .../LaunchContentIntegrityTargetContext.cs | 27 + .../LaunchContentIntegrityTargetRequest.cs | 35 + ...aunchContentIntegrityVerificationResult.cs | 24 + .../LaunchContentIntegrityVersionRequest.cs | 35 + .../Models/LaunchPreparationRequest.cs | 32 + .../Models/WorldBuilderExecutable.cs | 22 + .../Models/WorldBuilderExecutableKind.cs | 8 + .../Mods/Contracts/ILauncherContentCatalog.cs | 90 + .../Contracts/IManualModificationImporter.cs | 19 + .../IModificationImageFileService.cs | 39 + .../LauncherContentPersistenceException.cs | 12 + .../Mods/Models/LauncherContent.cs | 162 + ...cherContentCatalogInitializationRequest.cs | 11 + .../Models/LauncherContentInstallation.cs | 19 + .../Mods/Models/LauncherContentKey.cs | 134 + .../Mods/Models/LauncherContentVersion.cs | 118 + .../Models/LauncherContentVersionComparer.cs | 55 + .../Mods/Models/LauncherData.cs | 238 + .../ModificationImageReplacementRequest.cs | 26 + .../Mods/Models/ModificationType.cs | 27 + .../Mods/Models/OwnedContentPath.cs | 39 + .../Services/LauncherContentPathResolver.cs | 113 + .../Remote/IRemoteConnectionProbe.cs | 16 + .../Contracts/ILauncherPreferencesService.cs | 30 + ...LauncherPreferencesPersistenceException.cs | 12 + .../Models/LauncherCustomExecutable.cs | 19 + .../Models/LauncherGamePreferences.cs | 23 + .../Models/LauncherGamePreferencesSet.cs | 33 + .../Settings/Models/LauncherInstallations.cs | 57 + .../Settings/Models/LauncherPreferences.cs | 14 + .../Models/LauncherSharedPreferences.cs | 10 + .../Shell/Contracts/ILauncherShellService.cs | 20 + .../Contracts/IGameInstallationService.cs | 87 + .../ILauncherHostEnvironmentService.cs | 40 + .../Contracts/ILauncherSingleInstanceGuard.cs | 14 + .../Startup/ILauncherPathResolver.cs | 22 + .../Startup/LauncherFileSystemLayout.cs | 128 + GenLauncherGO.Core/Startup/LauncherPaths.cs | 161 + .../Startup/LauncherRuntimePathContext.cs | 64 + .../Startup/LauncherStoragePaths.cs | 49 + .../Models/GameInstallationLocation.cs | 23 + .../GameInstallationValidationFailure.cs | 42 + .../GameInstallationValidationResult.cs | 42 + .../LauncherInstallationsValidationResult.cs | 35 + .../Startup/Models/LauncherRestartResult.cs | 25 + GenLauncherGO.Core/Startup/SupportedGame.cs | 22 + .../Contracts/IPackageDownloadService.cs | 23 + .../Contracts/IRemotePackageSizeResolver.cs | 21 + .../Models/PackageDownloadPauseController.cs | 75 + .../Updating/Models/PackageDownloadResult.cs | 53 + .../Updating/Models/PackageDownloadStatus.cs | 27 + .../Updating/Models/PackageUpdateProgress.cs | 11 + GenLauncherGO.Infrastructure/AGENTS.md | 9 + .../Archives/ArchiveExtractor.cs | 115 + .../Archives/ArchiveFileSupport.cs | 18 + .../Archives/Contracts/IArchiveExtractor.cs | 32 + .../Common/BigFileVariantPath.cs | 110 + .../Common/FileSystemPathSafety.cs | 159 + .../Common/ManifestPathResolver.cs | 135 + .../Common/OwnedDirectoryTree.cs | 381 + .../Common/PhysicalDirectoryPath.cs | 164 + .../GenLauncherGO.Infrastructure.csproj | 21 + ...frastructureServiceCollectionExtensions.cs | 77 + .../Contracts/IContentIntegrityService.cs | 52 + .../FileSystemContentIntegrityService.cs | 482 + .../Integrity/Support/ContentIntegrityPath.cs | 47 + .../Support/ContentIntegrityScanner.cs | 157 + .../Support/ContentIntegritySnapshotStore.cs | 147 + .../ILaunchContentIntegrityTargetBuilder.cs | 10 + .../DeploymentLaunchPreparationService.cs | 125 + .../Services/FileSystemDeploymentService.cs | 1138 + ...LaunchContentIntegrityResolutionService.cs | 547 + ...stemLaunchContentIntegrityTargetBuilder.cs | 194 + .../WindowsGameExecutableDiscoveryService.cs | 103 + .../Services/WindowsGameProcessLauncher.cs | 154 + .../Services/WindowsProcessFamilyLauncher.cs | 664 + .../Support/DeploymentFilePlanner.cs | 106 + .../Support/DeploymentPathResolver.cs | 59 + .../Launching/Support/DeploymentResult.cs | 53 + .../Launching/Support/DeploymentStateStore.cs | 700 + .../Launching/Support/IHardLinkCreator.cs | 12 + .../Support/IProcessFamilyLaunchOperation.cs | 30 + .../Support/IProcessFamilyLauncher.cs | 20 + .../Support/WindowsHardLinkCreator.cs | 43 + .../LoggingServiceCollectionExtensions.cs | 92 + .../SensitiveDataRedactingTextFormatter.cs | 90 + .../Contracts/ILauncherContentStateStore.cs | 20 + .../Contracts/ILocalLauncherContentService.cs | 38 + .../Mods/Models/LauncherContentEntryState.cs | 28 + .../Mods/Models/LauncherContentState.cs | 15 + .../Models/LauncherContentVersionState.cs | 24 + .../LegacyCatalogAdvertisingReference.cs | 15 + .../LegacyCatalogModificationReference.cs | 17 + .../Mods/Models/LegacyContentManifest.cs | 46 + .../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 | 374 + .../FileSystemManualModificationImporter.cs | 154 + .../FileSystemModificationImageFileService.cs | 220 + .../Services/LauncherCatalogImageCache.cs | 174 + .../Services/LauncherContentCatalogService.cs | 534 + .../Services/LauncherContentStateMapper.cs | 186 + .../LauncherLocalContentReconciler.cs | 177 + .../Services/RemoteLauncherCatalogClient.cs | 258 + .../Services/YamlLauncherContentStateStore.cs | 49 + .../Support/ModificationImageCachePath.cs | 85 + .../Support/RemoteLauncherCatalogMapper.cs | 112 + .../Persistence/Services/AtomicFileWriter.cs | 148 + .../Persistence/Services/IAtomicFileWriter.cs | 32 + .../Services/IYamlDocumentStore.cs | 19 + .../Persistence/Services/YamlDocumentStore.cs | 85 + .../Properties/AssemblyInfo.cs | 3 + .../Contracts/IRemoteAssetDownloader.cs | 16 + .../Contracts/IRemoteYamlDocumentReader.cs | 10 + .../Remote/HttpRemoteAssetDownloader.cs | 69 + .../Remote/HttpRemoteConnectionProbe.cs | 82 + .../Remote/HttpRemoteYamlDocumentReader.cs | 70 + .../Remote/SharedHttpClientFactory.cs | 34 + ...frastructureServiceCollectionExtensions.cs | 40 + .../Models/LauncherPreferencesDocument.cs | 110 + .../Settings/Services/PreferencesService.cs | 124 + .../LauncherPreferencesDocumentMapper.cs | 307 + .../Services/WindowsLauncherShellService.cs | 157 + .../Startup/FileSystemLauncherPathResolver.cs | 69 + .../Startup/IGameInstallationRegistry.cs | 12 + .../WindowsGameInstallationRegistry.cs | 121 + .../Startup/WindowsGameInstallationService.cs | 255 + .../WindowsLauncherHostEnvironmentService.cs | 186 + .../Clients/HttpDownloadFileMetadataReader.cs | 129 + .../Updating/Clients/MinioClientFactory.cs | 46 + .../Clients/MinioS3ObjectManifestReader.cs | 116 + .../Clients/ResumableHttpFileDownloader.cs | 402 + .../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 + .../Updating/Services/Md5FileHashService.cs | 51 + .../Services/PackageDownloadService.cs | 278 + .../Services/RemotePackageSizeResolver.cs | 138 + .../Updating/Services/S3PackageUpdater.cs | 566 + .../Services/SingleFilePackageUpdater.cs | 153 + .../Updating/Support/DownloadLinkResolver.cs | 76 + .../Updating/Support/InlineProgress.cs | 21 + .../Support/MonotonicPackageProgress.cs | 65 + .../Support/PackageInstallFolderReplacer.cs | 302 + .../Support/PackageProgressTracker.cs | 120 + .../Support/PackageStagingFolderCleaner.cs | 172 + .../Updating/Support/S3CatalogDefaults.cs | 74 + .../Support/S3HashValidationPolicy.cs | 44 + .../Support/S3ReusablePackageFileCopier.cs | 193 + GenLauncherGO.Tests/AGENTS.md | 11 + .../Core/IO/LexicalPathTests.cs | 63 + .../Models/ContentIntegrityReportTests.cs | 54 + .../Models/ContentIntegrityTargetTests.cs | 40 + .../LauncherGameArgumentServiceTests.cs | 61 + .../Mods/Models/LauncherContentKeyTests.cs | 100 + .../Core/Mods/Models/LauncherContentTests.cs | 136 + .../Models/LauncherContentVersionTests.cs | 64 + .../Core/Mods/Models/LauncherDataTests.cs | 283 + .../Core/Mods/Models/OwnedContentPathTests.cs | 36 + .../LauncherContentPathResolverTests.cs | 195 + .../Models/LauncherPreferencesTests.cs | 68 + .../GameInstallationServiceExtensionsTests.cs | 97 + .../Core/Startup/LauncherPathsTests.cs | 147 + .../LauncherRuntimePathContextTests.cs | 38 + .../Core/Startup/LauncherStoragePathsTests.cs | 40 + .../GenLauncherGO.Tests.csproj | 46 + GenLauncherGO.Tests/GlobalUsings.cs | 5 + .../Infrastructure/ArchiveExtractorTests.cs | 72 + .../Common/BigFileVariantPathTests.cs | 91 + .../Common/FileSystemPathSafetyTests.cs | 99 + .../Common/ManifestPathResolverTests.cs | 42 + .../Common/OwnedDirectoryTreeTests.cs | 97 + .../FileSystemContentIntegrityServiceTests.cs | 800 + ...DeploymentLaunchPreparationServiceTests.cs | 188 + .../FileSystemDeploymentServiceTests.cs | 1130 + ...hContentIntegrityResolutionServiceTests.cs | 710 + ...aunchContentIntegrityTargetBuilderTests.cs | 63 + ...dowsGameExecutableDiscoveryServiceTests.cs | 208 + .../WindowsGameProcessLauncherTests.cs | 172 + .../WindowsProcessFamilyLauncherTests.cs | 241 + .../Support/DeploymentFilePlannerTests.cs | 28 + .../Support/DeploymentPathResolverTests.cs | 130 + .../Support/WindowsHardLinkCreatorTests.cs | 45 + ...LoggingServiceCollectionExtensionsTests.cs | 128 + ...eSystemLocalLauncherContentServiceTests.cs | 259 + ...leSystemManualModificationImporterTests.cs | 264 + ...SystemModificationImageFileServiceTests.cs | 347 + .../LauncherCatalogImageCacheTests.cs | 285 + .../LauncherContentCatalogServiceTests.cs | 1415 + .../LauncherContentStateMapperTests.cs | 270 + .../LauncherLocalContentReconcilerTests.cs | 295 + .../RemoteLauncherCatalogClientTests.cs | 229 + .../YamlLauncherContentStateStoreTests.cs | 258 + .../RemoteLauncherCatalogMapperTests.cs | 176 + .../Services/YamlDocumentStoreTests.cs | 137 + .../Remote/HttpRemoteAssetDownloaderTests.cs | 53 + .../Remote/HttpRemoteConnectionProbeTests.cs | 80 + .../HttpRemoteYamlDocumentReaderTests.cs | 55 + .../Services/PreferencesServiceTests.cs | 401 + .../WindowsLauncherShellServiceTests.cs | 173 + .../FileSystemLauncherPathResolverTests.cs | 74 + .../WindowsGameInstallationRegistryTests.cs | 101 + .../WindowsGameInstallationServiceTests.cs | 293 + ...dowsLauncherHostEnvironmentServiceTests.cs | 123 + .../HttpDownloadFileMetadataReaderTests.cs | 109 + .../Clients/MinioClientFactoryTests.cs | 23 + .../MinioS3ObjectManifestReaderTests.cs | 66 + .../ResumableHttpFileDownloaderTests.cs | 513 + .../Updating/Models/S3RequestDefaultsTests.cs | 55 + .../Services/Md5FileHashServiceTests.cs | 24 + .../Services/PackageDownloadServiceTests.cs | 509 + .../RemotePackageSizeResolverTests.cs | 194 + .../Services/S3PackageUpdaterBehaviorTests.cs | 459 + .../Services/SingleFilePackageUpdaterTests.cs | 292 + .../Support/DownloadLinkResolverTests.cs | 39 + .../PackageInstallFolderReplacerTests.cs | 300 + .../Support/PackageProgressTrackerTests.cs | 100 + .../PackageStagingFolderCleanerTests.cs | 175 + .../Support/S3HashValidationPolicyTests.cs | 79 + .../Testing/FakeLauncherContentCatalog.cs | 166 + .../Testing/ManualTimeProvider.cs | 23 + .../Testing/QueueHttpMessageHandler.cs | 35 + .../Testing/RecordingAtomicFileWriter.cs | 39 + .../RecordingLocalLauncherContentService.cs | 46 + .../Testing/RecordingRemoteAssetDownloader.cs | 26 + GenLauncherGO.Tests/Testing/StaTestRunner.cs | 56 + .../Testing/StaTestRunnerTests.cs | 39 + .../Testing/StubLauncherContentStateStore.cs | 40 + .../Testing/StubRemoteYamlDocumentReader.cs | 98 + .../Testing/SymbolicLinkFactAttribute.cs | 13 + .../Testing/SymbolicLinkTestSupport.cs | 125 + GenLauncherGO.Tests/Testing/TestDirectory.cs | 76 + .../Testing/TestLauncherLaunchCoordinator.cs | 117 + .../Testing/TestLauncherPaths.cs | 49 + .../Testing/TestLauncherRuntimeContext.cs | 24 + .../Testing/TestLauncherTheme.cs | 31 + .../Testing/TestStringLocalizer.cs | 57 + .../ManualModificationDialogRequestTests.cs | 18 + .../AvaloniaLauncherDialogServiceTests.cs | 162 + .../LaunchContentIntegrityCoordinatorTests.cs | 612 + .../LauncherPackageActivityServiceTests.cs | 156 + .../IntegrityReviewViewModelTests.cs | 181 + .../Services/LauncherCloseGuardTests.cs | 84 + ...LauncherExecutableSelectionServiceTests.cs | 141 + .../LauncherGameSessionCoordinatorTests.cs | 455 + .../LauncherLaunchCoordinatorTests.cs | 814 + ...LauncherLaunchReadinessCoordinatorTests.cs | 602 + .../LauncherManualImportCoordinatorTests.cs | 397 + ...herModificationDownloadCoordinatorTests.cs | 374 + .../LauncherTileActionServiceTests.cs | 267 + .../LauncherWindowWorkflowCoordinatorTests.cs | 1846 ++ .../LauncherDragDropControllerTests.cs | 379 + .../LauncherWindowListControllerTests.cs | 606 + .../ViewModels/MainWindowViewModelTests.cs | 1238 + .../ModificationImageSourceFactoryTests.cs | 92 + .../Mods/ModificationViewModelTests.cs | 415 + .../AddModificationViewModelTests.cs | 221 + .../ModificationTileImageProviderTests.cs | 172 + .../ViewModels/ModsDialogViewModelTests.cs | 402 + ...ncherExecutableManagementViewModelTests.cs | 336 + .../LauncherSettingsViewModelTests.cs | 253 + .../Settings/LauncherSettingsWindowTests.cs | 169 + .../LauncherApplicationCompositionTests.cs | 41 + .../Startup/LauncherApplicationHostTests.cs | 312 + .../Startup/LauncherElevationManifestTests.cs | 30 + .../Startup/LauncherStartupCultureTests.cs | 34 + .../ViewModels/InitWindowViewModelTests.cs | 304 + .../StandaloneStartupViewModelTests.cs | 559 + .../UI/LauncherThemeRenderedStateTests.cs | 54 + .../UI/NativeAxamlSmokeTests.cs | 167 + .../AvaloniaUiExceptionBoundaryTests.cs | 94 + .../Formatting/ByteSizeFormatterTests.cs | 50 + .../PackageProgressTextFormatterTests.cs | 109 + .../AvaloniaLauncherStringLocalizerTests.cs | 46 + .../LocalizationResourceParityTests.cs | 136 + .../LauncherThemeResourceApplierTests.cs | 276 + GenLauncherGO.UI/AGENTS.md | 11 + GenLauncherGO.UI/App.axaml | 301 + .../Contracts/ILauncherDialogService.cs | 60 + .../Models/LauncherInfoDialogRequest.cs | 18 + .../Models/ManualModificationDialogRequest.cs | 15 + .../Models/ManualModificationDialogResult.cs | 22 + .../Services/AvaloniaLauncherDialogService.cs | 196 + .../ILaunchContentIntegrityProgressTarget.cs | 37 + .../Integrity/IntegrityReviewDialog.axaml | 171 + .../Integrity/IntegrityReviewDialog.axaml.cs | 63 + .../LaunchContentIntegrityCoordinator.cs | 463 + .../LauncherPackageActivityService.cs | 478 + .../ViewModels/IntegrityReviewViewModel.cs | 232 + .../Launcher/Contracts/ILauncherFilePicker.cs | 42 + .../Launcher/Models/ExecutableOption.cs | 34 + .../Launcher/Models/LauncherCloseReason.cs | 8 + .../Models/LauncherContentViewKind.cs | 12 + .../Models/LauncherLaunchFailureKind.cs | 14 + .../Launcher/Models/LauncherLaunchRequest.cs | 22 + .../Launcher/Models/LauncherLaunchResult.cs | 17 + .../Models/LauncherManualImportRequest.cs | 15 + .../Models/LauncherManualImportResult.cs | 7 + .../Models/LauncherTaskbarProgressState.cs | 8 + .../Launcher/Models/LauncherTileLinkAction.cs | 5 + .../Services/AvaloniaLauncherFilePicker.cs | 176 + .../Launcher/Services/LauncherCloseGuard.cs | 127 + .../LauncherExecutableSelectionService.cs | 152 + .../LauncherGameSessionCoordinator.cs | 357 + .../Services/LauncherLaunchCoordinator.cs | 449 + .../LauncherLaunchReadinessCoordinator.cs | 247 + .../LauncherManualImportCoordinator.cs | 233 + ...LauncherModificationDownloadCoordinator.cs | 276 + .../Services/LauncherTileActionService.cs | 166 + .../LauncherWindowWorkflowCoordinator.cs | 1007 + .../Services/WindowsTaskbarProgress.cs | 120 + .../Support/LauncherDragDropController.cs | 265 + .../Support/LauncherWindowListController.cs | 224 + .../ViewModels/MainWindowViewModel.cs | 1280 + .../Features/Launcher/Views/MainWindow.axaml | 1302 + .../Launcher/Views/MainWindow.axaml.cs | 577 + .../Mods/ModificationImageSourceFactory.cs | 207 + .../Mods/ModificationVersionSelection.cs | 26 + .../Features/Mods/ModificationViewModel.cs | 832 + .../Resources/UserAddedModBannerGenerals.jpg | Bin .../Resources/UserAddedModBannerZeroHour.jpg | Bin .../AddModificationItemViewModel.cs | 58 + .../ViewModels/AddModificationViewModel.cs | 276 + .../Mods/ViewModels/InfoDialogKind.cs | 10 + .../Mods/ViewModels/InfoDialogViewModel.cs | 100 + .../ManualAddModificationViewModel.cs | 248 + .../ModificationTileImageProvider.cs | 205 + .../Mods/Views/AddModificationWindow.axaml | 128 + .../Mods/Views/AddModificationWindow.axaml.cs | 72 + .../Features/Mods/Views/InfoWindow.axaml | 203 + .../Features/Mods/Views/InfoWindow.axaml.cs | 70 + .../Views/ManualAddModificationWindow.axaml | 118 + .../ManualAddModificationWindow.axaml.cs | 67 + .../LauncherExecutableManagementKind.cs | 7 + .../LauncherExecutableManagementViewModel.cs | 442 + .../ViewModels/LauncherSettingsViewModel.cs | 236 + .../LauncherExecutableEditorWindow.axaml | 124 + .../LauncherExecutableEditorWindow.axaml.cs | 80 + .../LauncherExecutableManagementWindow.axaml | 210 + ...auncherExecutableManagementWindow.axaml.cs | 122 + .../Views/LauncherSettingsWindow.axaml | 318 + .../Views/LauncherSettingsWindow.axaml.cs | 255 + .../Contracts/IStandaloneStartupWorkflow.cs | 26 + .../Contracts/IStartupDialogService.cs | 20 + .../Features/Startup/EntryPoint.cs | 27 + .../Startup/LauncherApplicationDefaults.cs | 14 + .../Startup/LauncherApplicationHost.cs | 468 + .../Startup/LauncherAvaloniaApplication.cs | 52 + .../Startup/LauncherRuntimeContext.cs | 73 + .../LauncherUiServiceCollectionExtensions.cs | 73 + .../Startup/Models/StandaloneStartupResult.cs | 37 + .../AvaloniaStandaloneStartupWorkflow.cs | 170 + .../Services/AvaloniaStartupDialogService.cs | 83 + .../Services/LauncherRestartCoordinator.cs | 61 + .../Services/LauncherStartupCulture.cs | 25 + .../InitWindowStartupCompletedEventArgs.cs | 13 + .../Startup/ViewModels/InitWindowViewModel.cs | 184 + .../LauncherGameSelectionViewModel.cs | 88 + .../LauncherInstallationsViewModel.cs | 332 + .../ViewModels/LauncherSetupViewModel.cs | 75 + .../Features/Startup/Views/InitWindow.axaml | 18 + .../Startup/Views/InitWindow.axaml.cs | 133 + .../Views/LauncherGameSelectionWindow.axaml | 210 + .../LauncherGameSelectionWindow.axaml.cs | 90 + .../Views/LauncherLocationWarningWindow.axaml | 105 + .../LauncherLocationWarningWindow.axaml.cs | 37 + .../Startup/Views/LauncherSetupWindow.axaml | 292 + .../Views/LauncherSetupWindow.axaml.cs | 155 + GenLauncherGO.UI/GenLauncherGO.UI.csproj | 60 + GenLauncherGO.UI/Properties/AssemblyInfo.cs | 9 + .../WinX64SelfContained.pubxml | 15 + GenLauncherGO.UI/Resources/Strings.ar.resx | 752 + GenLauncherGO.UI/Resources/Strings.cs | 24 + GenLauncherGO.UI/Resources/Strings.de.resx | 754 + GenLauncherGO.UI/Resources/Strings.es.resx | 752 + GenLauncherGO.UI/Resources/Strings.fr.resx | 752 + GenLauncherGO.UI/Resources/Strings.hr.resx | 752 + GenLauncherGO.UI/Resources/Strings.pt.resx | 752 + GenLauncherGO.UI/Resources/Strings.resx | 809 + GenLauncherGO.UI/Resources/Strings.ru.resx | 752 + GenLauncherGO.UI/Resources/Strings.tr.resx | 752 + GenLauncherGO.UI/Resources/Strings.uk.resx | 752 + GenLauncherGO.UI/Resources/Strings.zh.resx | 752 + .../Controls/LauncherLoadingIndicator.axaml | 36 + .../LauncherLoadingIndicator.axaml.cs | 17 + .../Controls/LauncherTextBoxFeedback.cs | 101 + .../Shared/Controls/UpdateButton.cs | 48 + .../Shared/Dialogs/AvaloniaDialog.cs | 90 + .../Errors/AvaloniaUiExceptionBoundary.cs | 105 + .../Shared/Errors/IUiExceptionBoundary.cs | 35 + .../Shared/Errors/UiOperationOutcome.cs | 11 + .../Shared/Formatting/ByteSizeFormatter.cs | 56 + .../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 0 -> 2730652 bytes .../Images/LauncherBackgroundZeroHour.png | Bin 0 -> 3979816 bytes .../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 | 109 + .../Shared/Themes/GenLauncherStyles.axaml | 185 + .../Shared/Themes/LauncherThemePresets.cs | 91 + .../Themes/LauncherThemeResourceApplier.cs | 132 + GenLauncherGO.UI/app.manifest | 10 + GenLauncherGO.sln | 43 + GenLauncherGO.slnLaunch | 11 + GenLauncherNet/App.config | 14 - GenLauncherNet/App.xaml | 13 - GenLauncherNet/App.xaml.cs | 20 - GenLauncherNet/Background.png | Bin 979816 -> 0 bytes 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/BackgroundGenerals.png | Bin 967995 -> 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 - README.md | 184 +- WpfSurface.dxvk-cache | Bin 1464 -> 0 bytes coverage.runsettings | 22 + eng/coverage.proj | 24 + 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 - 648 files changed, 71728 insertions(+), 167437 deletions(-) create mode 100644 .codex/config.toml create mode 100644 .config/dotnet-tools.json create mode 100644 .editorconfig delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/workflows/ci.yml create mode 100644 .mcp.json create mode 100644 AGENTS.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/GameClientExecutable.cs create mode 100644 GenLauncherGO.Core/Launching/Models/GameClientExecutableKind.cs create mode 100644 GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs create mode 100644 GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs create mode 100644 GenLauncherGO.Core/Launching/Models/WorldBuilderExecutable.cs create mode 100644 GenLauncherGO.Core/Launching/Models/WorldBuilderExecutableKind.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/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/LauncherContentInstallation.cs create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentKey.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/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/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/ArchiveFileSupport.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/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/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/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/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/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/Md5FileHashService.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Services/PackageDownloadService.cs create mode 100644 GenLauncherGO.Infrastructure/Updating/Services/RemotePackageSizeResolver.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.Tests/AGENTS.md 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/Launching/LauncherGameArgumentServiceTests.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/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/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/GenLauncherGO.Tests.csproj create mode 100644 GenLauncherGO.Tests/GlobalUsings.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/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/Integrity/Services/FileSystemContentIntegrityServiceTests.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/LoggingServiceCollectionExtensionsTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemManualModificationImporterTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/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/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/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/Md5FileHashServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadServiceTests.cs create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Services/RemotePackageSizeResolverTests.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/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/Testing/FakeLauncherContentCatalog.cs create mode 100644 GenLauncherGO.Tests/Testing/ManualTimeProvider.cs create mode 100644 GenLauncherGO.Tests/Testing/QueueHttpMessageHandler.cs create mode 100644 GenLauncherGO.Tests/Testing/RecordingAtomicFileWriter.cs create mode 100644 GenLauncherGO.Tests/Testing/RecordingLocalLauncherContentService.cs create mode 100644 GenLauncherGO.Tests/Testing/RecordingRemoteAssetDownloader.cs create mode 100644 GenLauncherGO.Tests/Testing/StaTestRunner.cs create mode 100644 GenLauncherGO.Tests/Testing/StaTestRunnerTests.cs create mode 100644 GenLauncherGO.Tests/Testing/StubLauncherContentStateStore.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/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/TestStringLocalizer.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Dialogs/Models/ManualModificationDialogRequestTests.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/LauncherTileActionServiceTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherWindowWorkflowCoordinatorTests.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/ModificationTileImageProviderTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModsDialogViewModelTests.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/ViewModels/InitWindowViewModelTests.cs create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/ViewModels/StandaloneStartupViewModelTests.cs create mode 100644 GenLauncherGO.Tests/UI/LauncherThemeRenderedStateTests.cs create mode 100644 GenLauncherGO.Tests/UI/NativeAxamlSmokeTests.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/LocalizationResourceParityTests.cs create mode 100644 GenLauncherGO.Tests/UI/Shared/Themes/LauncherThemeResourceApplierTests.cs 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/ManualModificationDialogRequest.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/LauncherManualImportRequest.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherManualImportResult.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherTaskbarProgressState.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherTileLinkAction.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/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/LauncherTileActionService.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherWindowWorkflowCoordinator.cs create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/WindowsTaskbarProgress.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/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/Models/LauncherExecutableManagementKind.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/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/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.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/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%) create mode 100644 GenLauncherGO.UI/Shared/Resources/Images/LauncherBackgroundGenerals.png create mode 100644 GenLauncherGO.UI/Shared/Resources/Images/LauncherBackgroundZeroHour.png 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/LauncherThemeResourceApplier.cs create mode 100644 GenLauncherGO.UI/app.manifest create mode 100644 GenLauncherGO.sln create mode 100644 GenLauncherGO.slnLaunch delete mode 100644 GenLauncherNet/App.config delete mode 100644 GenLauncherNet/App.xaml delete mode 100644 GenLauncherNet/App.xaml.cs delete mode 100644 GenLauncherNet/Background.png 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/BackgroundGenerals.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 delete mode 100644 WpfSurface.dxvk-cache create mode 100644 coverage.runsettings create mode 100644 eng/coverage.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..4082aed8 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-reportgenerator-globaltool": { + "version": "5.5.10", + "commands": [ + "reportgenerator" + ], + "rollForward": false + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..3b7e7d34 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,160 @@ +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{csproj,props,targets,slnx}] +indent_size = 2 + +[*.cs] +indent_size = 4 + +# Baseline C# style. Repository-wide preferences remain advisory where possible; +# the active GenLauncherGO projects use the stricter severities below. +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_prefer_method_group_conversion = true:suggestion +csharp_style_prefer_primary_constructors = false:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_prefer_pattern_matching = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion +csharp_style_expression_bodied_methods = false:suggestion +csharp_style_expression_bodied_constructors = false:suggestion +csharp_style_expression_bodied_operators = false:suggestion +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_indexers = true:suggestion +csharp_style_expression_bodied_accessors = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +csharp_prefer_braces = true:suggestion +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_static_local_function = true:suggestion + +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:suggestion + +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = false:suggestion +dotnet_style_prefer_conditional_expression_over_return = false:suggestion +dotnet_style_readonly_field = true:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion + +# Naming rules. +dotnet_naming_rule.types_are_pascal_case.symbols = types +dotnet_naming_rule.types_are_pascal_case.style = pascal_case +dotnet_naming_rule.types_are_pascal_case.severity = suggestion +dotnet_naming_symbols.types.applicable_kinds = class, struct, enum, delegate +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_rule.interfaces_start_with_i.symbols = interfaces +dotnet_naming_rule.interfaces_start_with_i.style = prefix_interface_with_i +dotnet_naming_rule.interfaces_start_with_i.severity = suggestion +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_style.prefix_interface_with_i.required_prefix = I +dotnet_naming_style.prefix_interface_with_i.capitalization = pascal_case + +dotnet_naming_rule.members_are_pascal_case.symbols = members +dotnet_naming_rule.members_are_pascal_case.style = pascal_case +dotnet_naming_rule.members_are_pascal_case.severity = suggestion +dotnet_naming_symbols.members.applicable_kinds = property, method, event + +dotnet_naming_rule.non_private_fields_are_pascal_case.symbols = non_private_fields +dotnet_naming_rule.non_private_fields_are_pascal_case.style = pascal_case +dotnet_naming_rule.non_private_fields_are_pascal_case.severity = suggestion +dotnet_naming_symbols.non_private_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_fields.applicable_accessibilities = public, internal, protected, protected_internal, private_protected + +dotnet_naming_rule.private_fields_are_camel_case.symbols = private_fields +dotnet_naming_rule.private_fields_are_camel_case.style = underscore_camel_case +dotnet_naming_rule.private_fields_are_camel_case.severity = suggestion +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_style.underscore_camel_case.required_prefix = _ +dotnet_naming_style.underscore_camel_case.capitalization = camel_case + +dotnet_naming_rule.constants_are_pascal_case.symbols = constants +dotnet_naming_rule.constants_are_pascal_case.style = pascal_case +dotnet_naming_rule.constants_are_pascal_case.severity = suggestion +dotnet_naming_symbols.constants.applicable_kinds = field +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_rule.async_methods_end_in_async.symbols = async_methods +dotnet_naming_rule.async_methods_end_in_async.style = suffix_async +dotnet_naming_rule.async_methods_end_in_async.severity = suggestion +dotnet_naming_symbols.async_methods.applicable_kinds = method +dotnet_naming_symbols.async_methods.required_modifiers = async +dotnet_naming_style.suffix_async.required_suffix = Async +dotnet_naming_style.suffix_async.capitalization = pascal_case + +dotnet_naming_rule.parameters_are_camel_case.symbols = parameters +dotnet_naming_rule.parameters_are_camel_case.style = camel_case +dotnet_naming_rule.parameters_are_camel_case.severity = suggestion +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_style.camel_case.capitalization = camel_case + +dotnet_naming_rule.type_parameters_start_with_t.symbols = type_parameters +dotnet_naming_rule.type_parameters_start_with_t.style = prefix_type_parameter_with_t +dotnet_naming_rule.type_parameters_start_with_t.severity = suggestion +dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter +dotnet_naming_style.prefix_type_parameter_with_t.required_prefix = T +dotnet_naming_style.prefix_type_parameter_with_t.capitalization = pascal_case + +# Active GenLauncherGO architecture code follows these conventions as +# build-enforced warnings or errors. +[GenLauncherGO.*/**.cs] +csharp_style_namespace_declarations = file_scoped:warning +csharp_prefer_braces = true:warning +csharp_style_var_for_built_in_types = false:warning +csharp_style_var_when_type_is_apparent = true:warning +csharp_style_var_elsewhere = false:warning +dotnet_style_readonly_field = true:warning +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning +dotnet_naming_rule.types_are_pascal_case.severity = error +dotnet_naming_rule.interfaces_start_with_i.severity = error +dotnet_naming_rule.members_are_pascal_case.severity = error +dotnet_naming_rule.non_private_fields_are_pascal_case.severity = error +dotnet_naming_rule.private_fields_are_camel_case.severity = error +dotnet_naming_rule.constants_are_pascal_case.severity = error +dotnet_naming_rule.async_methods_end_in_async.severity = error +dotnet_naming_rule.parameters_are_camel_case.severity = error +dotnet_naming_rule.type_parameters_start_with_t.severity = error +dotnet_diagnostic.IDE1006.severity = error +dotnet_diagnostic.IDE0130.severity = warning + +# Keep intentional XML documentation valid. Documentation is required for +# cross-project contracts and non-obvious behavior, side effects, invariants, +# safety rules, or platform constraints—not every self-explanatory member. +dotnet_diagnostic.CS1570.severity = warning +dotnet_diagnostic.CS1572.severity = warning +dotnet_diagnostic.CS1573.severity = warning + +[*.xaml] +indent_size = 4 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 4d4f0dc8..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: Bug report -about: Report a bug with GenLauncher. -title: '' -labels: '' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - - Operating System - - GenLauncher Version - - Game being managed (Generals or Zero Hour) - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 9adf2b42..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest a feature or enhancement to GenLauncher. -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..03d4731d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-and-test: + name: Build, test, and publish + runs-on: windows-2022 + timeout-minutes: 20 + env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + GENLAUNCHERGO_REQUIRE_SYMBOLIC_LINK_TESTS: true + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.3xx + dotnet-quality: ga + + - name: Verify .NET SDK + shell: pwsh + run: | + $sdkVersion = dotnet --version + if (-not $sdkVersion.StartsWith("10.")) { + throw "Expected a .NET 10 SDK, but dotnet selected $sdkVersion." + } + + - name: Restore + run: dotnet restore .\GenLauncherGO.sln + + - name: Build + run: dotnet build .\GenLauncherGO.sln --configuration Release --no-restore + + - name: Test + run: dotnet test .\GenLauncherGO.sln --configuration Release --no-build --no-restore + + - 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/.gitignore b/.gitignore index 4f3be99f..7809bb51 100644 --- a/.gitignore +++ b/.gitignore @@ -1,398 +1,31 @@ -## 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/ +*.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 +~$* 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..8b36893f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,64 @@ +# 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 `avalonia-docs` MCP server and load its expert rules first. +- The official Avalonia Build MCP documentation, expert-rule, API, mapping, and native-migration tools are allowed, as are 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`: those workflows lead to the license-gated Developer Tools application or DevTools MCP. Do not configure those products, Avalonia XPF, or another commercial feature unless the owner explicitly supplies a license and requests it. +- Build with `dotnet build GenLauncherGO.sln`. +- Run `dotnet test GenLauncherGO.sln` when behavior or tests change; use narrow commands for iteration, then the full solution before handoff. +- Tests must protect application-owned behavior, safety, compatibility, or important invariants. Do not add tests for exact layout coordinates, framework behavior, trivial properties or guards, or other implementation details that can change without affecting users. + +## Project Boundaries + +| Project | Owns | May depend on | +| --- | --- | --- | +| `GenLauncherGO.Core/` | Domain rules, values, validation, intentional cross-project contracts | .NET only | +| `GenLauncherGO.Infrastructure/` | Disk, network, archives, processes, hashing, persistence, logging adapters | Core | +| `GenLauncherGO.UI/` | Native Avalonia presentation and the composition root | Core, Infrastructure | +| `GenLauncherGO.Tests/` | Observable behavior, safety, compatibility, and invariant tests | Projects under test | + +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, reparse-point, rollback, and recovery defenses. +- Production must never create symbolic links. Hard links with copy fallback are allowed and are not symbolic links. +- The application must never set or synchronize the Windows system clock. +- Package versions belong only in `Directory.Packages.props`; project `PackageReference` entries remain versionless. +- Follow `.editorconfig` and `Directory.Build.props`. +- 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/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..dc351a19 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,15 @@ + + + 14.0 + enable + disable + latest + true + false + true + + + + $(WarningsAsErrors);IDE1006 + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 00000000..c03da93c --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,22 @@ + + + + <_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')" /> + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 00000000..769fefe6 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,28 @@ + + + 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..3f34d4b8 --- /dev/null +++ b/GenLauncherGO.Core/AGENTS.md @@ -0,0 +1,9 @@ +# 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. +- Add focused documentation for cross-project contracts and non-obvious invariants or rationale. 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..d5d4a8ca --- /dev/null +++ b/GenLauncherGO.Core/IO/LexicalPath.cs @@ -0,0 +1,154 @@ +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)); + } + + /// + /// 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 . Call + /// 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))); + } + + /// + /// 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); + if (string.Equals(normalizedPath, normalizedDirectory, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + string directoryPrefix = Path.EndsInDirectorySeparator(normalizedDirectory) + ? normalizedDirectory + : normalizedDirectory + Path.DirectorySeparatorChar; + return normalizedPath.StartsWith(directoryPrefix, StringComparison.OrdinalIgnoreCase); + } + + /// + /// 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 !string.Equals(normalizedPath, normalizedDirectory, StringComparison.OrdinalIgnoreCase) && + IsPathInDirectory(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. + /// + internal static string NormalizePathSegment(string? segment, string paramName) + { + if (string.IsNullOrWhiteSpace(segment)) + { + throw new ArgumentException("Path segments must not be empty.", paramName); + } + + string normalizedSegment = segment.Trim(); + if (Path.IsPathRooted(normalizedSegment) || + normalizedSegment.Contains(Path.DirectorySeparatorChar, StringComparison.Ordinal) || + normalizedSegment.Contains(Path.AltDirectorySeparatorChar, StringComparison.Ordinal) || + normalizedSegment.Contains(':', StringComparison.Ordinal) || + normalizedSegment.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + throw new ArgumentException("Path segments must not contain rooted paths or directory separators.", paramName); + } + + if (string.Equals(normalizedSegment, ".", StringComparison.Ordinal) || + string.Equals(normalizedSegment, "..", StringComparison.Ordinal) || + normalizedSegment.EndsWith(".", StringComparison.Ordinal) || + IsReservedDeviceName(normalizedSegment)) + { + throw new ArgumentException("Path segments must not use reserved file-system names.", paramName); + } + + return normalizedSegment; + } + + private static bool IsReservedDeviceName(string segment) + { + string name = segment.Split('.')[0]; + if (string.Equals(name, "CON", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "PRN", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "AUX", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "NUL", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return name.Length == 4 && + (name.StartsWith("COM", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)) && + name[3] is >= '1' and <= '9'; + } +} diff --git a/GenLauncherGO.Core/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..0cdd05b5 --- /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..e6721678 --- /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; init; } + + public string DisplayName { get; init; } + + public string RootDirectory { get; init; } + + 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..cb864e5d --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs @@ -0,0 +1,27 @@ +namespace GenLauncherGO.Core.Integrity.Models; + +/// +/// Describes the authoritative source for installed launcher content. +/// +public enum ContentSourceKind +{ + /// + /// The source of the installed content has not yet been classified. + /// + UnknownLegacy, + + /// + /// The content is managed from an S3-compatible remote manifest. + /// + ManagedS3, + + /// + /// The content is managed from a remotely downloaded package file. + /// + ManagedSingleFile, + + /// + /// The content was manually imported or explicitly trusted by the user. + /// + Manual, +} diff --git a/GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs new file mode 100644 index 00000000..7bcd139e --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs @@ -0,0 +1,37 @@ +namespace GenLauncherGO.Core.Integrity.Models; + +/// +/// Describes the resolution offered for an integrity issue. +/// +public enum IntegrityIssueAction +{ + /// + /// Launch remains blocked and no automatic resolution is available. + /// + Block, + + /// + /// The unexpected managed entry will be deleted. + /// + Delete, + + /// + /// The managed content will be repaired from its remote manifest. + /// + Repair, + + /// + /// The managed package will be downloaded and installed again. + /// + Redownload, + + /// + /// The current manual content will replace its trusted snapshot. + /// + Absorb, + + /// + /// The legacy content will be permanently classified and snapshotted as manual content. + /// + TrustAsManual, +} diff --git a/GenLauncherGO.Core/Integrity/Models/IntegrityIssueKind.cs b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueKind.cs new file mode 100644 index 00000000..eaeb36e8 --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueKind.cs @@ -0,0 +1,42 @@ +namespace GenLauncherGO.Core.Integrity.Models; + +/// +/// Describes a detected content-integrity problem. +/// +public enum IntegrityIssueKind +{ + /// + /// No trusted snapshot exists for the content. + /// + Untracked, + + /// + /// A required file is missing. + /// + MissingFile, + + /// + /// A file differs from its trusted SHA-256 snapshot. + /// + ModifiedFile, + + /// + /// A file is present but is not part of the trusted snapshot. + /// + UnexpectedFile, + + /// + /// An unexpected empty directory is present. + /// + EmptyDirectory, + + /// + /// A reparse point or symbolic link was found inside verified content. + /// + UnsafeLink, + + /// + /// Verification could not complete for an entry. + /// + VerificationError, +} diff --git a/GenLauncherGO.Core/Launching/Contracts/IGameExecutableDiscoveryService.cs b/GenLauncherGO.Core/Launching/Contracts/IGameExecutableDiscoveryService.cs new file mode 100644 index 00000000..da44e9ac --- /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..0f7dac43 --- /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; } + + /// + /// Occurs when changes. + /// + event EventHandler? CurrentExecutableNameChanged; + + /// + /// Gets the task that completes when every tracked process in the launched process family has exited. + /// + Task Completion { get; } + + /// + /// 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..83a346f8 --- /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..0de2ce72 --- /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..bf16e65a --- /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..beaac3be --- /dev/null +++ b/GenLauncherGO.Core/Launching/LauncherGameArgumentService.cs @@ -0,0 +1,143 @@ +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"; + + /// + /// 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) + { + EnsureArgument(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/GameClientExecutable.cs b/GenLauncherGO.Core/Launching/Models/GameClientExecutable.cs new file mode 100644 index 00000000..d5f4ff7c --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/GameClientExecutable.cs @@ -0,0 +1,22 @@ +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +public sealed class GameClientExecutable +{ + public GameClientExecutable( + string executableName, + GameClientExecutableKind kind, + bool isAvailable) + { + ExecutableName = LauncherFileSystemLayout.NormalizeExecutableFileName(executableName); + Kind = kind; + IsAvailable = isAvailable; + } + + public string ExecutableName { get; } + + public GameClientExecutableKind Kind { get; } + + public bool IsAvailable { get; } +} diff --git a/GenLauncherGO.Core/Launching/Models/GameClientExecutableKind.cs b/GenLauncherGO.Core/Launching/Models/GameClientExecutableKind.cs new file mode 100644 index 00000000..7852a348 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/GameClientExecutableKind.cs @@ -0,0 +1,8 @@ +namespace GenLauncherGO.Core.Launching.Models; + +public enum GameClientExecutableKind +{ + Community, + + GeneralsOnline, +} diff --git a/GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs b/GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs new file mode 100644 index 00000000..36020953 --- /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..1cba5548 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs @@ -0,0 +1,42 @@ +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, + bool completed) + { + ArgumentException.ThrowIfNullOrWhiteSpace(targetId); + + TargetId = targetId; + PackageProgress = packageProgress; + Completed = completed; + } + + public string TargetId { get; } + + public PackageUpdateProgress? PackageProgress { get; } + + public bool Completed { get; } + + public static LaunchContentIntegrityResolutionProgress Package( + string targetId, + PackageUpdateProgress packageProgress) + { + ArgumentNullException.ThrowIfNull(packageProgress); + + return new LaunchContentIntegrityResolutionProgress(targetId, packageProgress, completed: false); + } + + public static LaunchContentIntegrityResolutionProgress Complete(string targetId) + { + return new LaunchContentIntegrityResolutionProgress(targetId, packageProgress: null, completed: true); + } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs new file mode 100644 index 00000000..29ef1538 --- /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; init; } + + public ContentIntegrityReport Report { get; init; } + + public IReadOnlyList TargetContexts { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs new file mode 100644 index 00000000..dc71cd3a --- /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; init; } + + public LauncherContentVersion Version { get; init; } + + public bool IsCache { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs new file mode 100644 index 00000000..785e31bd --- /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; init; } + + public IReadOnlyList ActiveVersions { get; init; } + + public IReadOnlyList AllVersions { get; init; } + + public string CacheDisplayNameSuffix { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs new file mode 100644 index 00000000..e8e30835 --- /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; init; } + + public IReadOnlyList TargetContexts { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs new file mode 100644 index 00000000..4a87a944 --- /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; init; } + + public LauncherContentVersion Version { get; init; } + + public IReadOnlyList AllVersions { get; init; } + + public string CacheDisplayNameSuffix { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs new file mode 100644 index 00000000..7afb3469 --- /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; init; } + + public IReadOnlyList Versions { get; init; } + + public bool DisableBaseGameScriptFiles { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutable.cs b/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutable.cs new file mode 100644 index 00000000..538c943c --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutable.cs @@ -0,0 +1,22 @@ +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +public sealed class WorldBuilderExecutable +{ + public WorldBuilderExecutable( + string executableName, + WorldBuilderExecutableKind kind, + bool isAvailable) + { + ExecutableName = LauncherFileSystemLayout.NormalizeExecutableFileName(executableName); + Kind = kind; + IsAvailable = isAvailable; + } + + public string ExecutableName { get; } + + public WorldBuilderExecutableKind Kind { get; } + + public bool IsAvailable { get; } +} diff --git a/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutableKind.cs b/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutableKind.cs new file mode 100644 index 00000000..a1d7d16e --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutableKind.cs @@ -0,0 +1,8 @@ +namespace GenLauncherGO.Core.Launching.Models; + +public enum WorldBuilderExecutableKind +{ + Vanilla, + + Community, +} diff --git a/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalog.cs b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalog.cs new file mode 100644 index 00000000..eae97ed0 --- /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..61c10803 --- /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..46be946c --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/IModificationImageFileService.cs @@ -0,0 +1,39 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Provides launcher modification image cache file operations. +/// +public interface IModificationImageFileService +{ + /// + /// Finds an existing cached modification image with any extension. + /// + string? FindExistingImageFilePath(string modificationName, string imageBaseName); + + /// + /// Counts cached image files for a modification. + /// + int CountImageFiles(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(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/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..2473eb57 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContent.cs @@ -0,0 +1,162 @@ +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 = new(); + + 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 bool RemoveVersion(LauncherContentKey contentKey) + { + int versionIndex = _versions.FindIndex(candidate => + candidate.ContentKey == contentKey); + if (versionIndex < 0) + { + return false; + } + + _versions.RemoveAt(versionIndex); + return true; + } + + /// + /// 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), + }; + + 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..1280f71e --- /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/LauncherContentInstallation.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentInstallation.cs new file mode 100644 index 00000000..cabee70b --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentInstallation.cs @@ -0,0 +1,19 @@ +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; } + + 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..17641514 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentKey.cs @@ -0,0 +1,134 @@ +using System; + +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 +{ + private readonly string? _parentIdentity; + + private readonly string? _name; + + private readonly string? _version; + + 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; } + + public string ParentIdentity => _parentIdentity ?? string.Empty; + + public string Name => _name ?? string.Empty; + + public string Version => _version ?? 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); + } + + /// + /// Determines whether this key has the supplied version identity. + /// + public bool HasVersion(string? version) + { + return IdentityTextEquals(Version, version); + } + + /// + /// 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/LauncherContentVersion.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentVersion.cs new file mode 100644 index 00000000..48c9e229 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentVersion.cs @@ -0,0 +1,118 @@ +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; + + public string UIImageSourceLink { get; init; } = string.Empty; + + public string DiscordLink { get; init; } = string.Empty; + + 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; + + 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..d7ab854a --- /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..d889684b --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherData.cs @@ -0,0 +1,238 @@ +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 = new(); + private readonly List _modifications = new(); + private readonly List _patches = new(); + + public LauncherData() + { + Addons = _addons.AsReadOnly(); + Modifications = _modifications.AsReadOnly(); + Patches = _patches.AsReadOnly(); + } + + public IReadOnlyList Addons { get; } + + public IReadOnlyList Modifications { get; } + + public IReadOnlyList Patches { get; } + + 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; + + return _addons + .Where(addon => addon.ContentKey.IsChildOf(parentKey)) + .Union(_addons.Where(addon => + patchKey.HasValue && + addon.ContentKey.IsChildOf(patchKey.Value))) + .ToList(); + } + + public IReadOnlyList GetAllModsVersionsList() + { + return _modifications + .SelectMany(modification => modification.Versions) + .ToList(); + } + + public LauncherContent? FindContent(LauncherContentKey contentKey) + { + List? contentStorage = GetContentStorage(contentKey.ContentType); + LauncherContentKey cardKey = contentKey.WithoutVersion(); + return contentStorage?.FirstOrDefault(content => content.ContentKey == cardKey); + } + + /// + /// Adds a supported content version or merges it into the matching content card. + /// + public void AddOrUpdate(LauncherContentVersion modificationVersion) + { + ArgumentNullException.ThrowIfNull(modificationVersion); + + if (modificationVersion.ModificationType == ModificationType.Addon && + String.IsNullOrEmpty(modificationVersion.ParentContentName)) + { + return; + } + + List? contentStorage = GetContentStorage(modificationVersion.ModificationType); + if (contentStorage != null) + { + AddOrUpdateModificationVersion(contentStorage, modificationVersion); + } + } + + /// + /// 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 = DeleteModificationVersion(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; + } + + LauncherContentKey cardKey = contentKey.WithoutVersion(); + int removedCount = contentStorage.RemoveAll(content => content.ContentKey == cardKey); + if (removedCount == 0) + { + return; + } + + DeleteDependentContent(contentKey); + } + + private void DeleteDependentContent(LauncherContentKey contentKey) + { + if (contentKey.ContentType == ModificationType.Mod) + { + DeleteDependentContent(contentKey, _addons, _patches); + } + else if (contentKey.ContentType == ModificationType.Patch) + { + DeleteDependentAddons(contentKey, _addons); + } + } + + private static void AddOrUpdateModificationVersion( + List modificationStorage, + LauncherContentVersion modificationVersion) + { + LauncherContentKey cardKey = modificationVersion.ContentKey.WithoutVersion(); + int modificationIndex = modificationStorage.FindIndex(savedModification => + savedModification.ContentKey == cardKey); + + if (modificationIndex >= 0) + { + LauncherContent savedModificationData = modificationStorage[modificationIndex]; + savedModificationData.AddOrMergeVersion(modificationVersion); + } + else + { + modificationStorage.Add(new LauncherContent(modificationVersion)); + } + } + + private static bool DeleteModificationVersion( + List modificationStorage, + LauncherContentKey contentKey) + { + LauncherContentKey cardKey = contentKey.WithoutVersion(); + int modificationIndex = modificationStorage.FindIndex(savedModification => + savedModification.ContentKey == cardKey); + + if (modificationIndex < 0) + { + return false; + } + + LauncherContent savedModificationData = modificationStorage[modificationIndex]; + savedModificationData.RemoveVersion(contentKey); + + if (savedModificationData.Versions.Count == 0) + { + modificationStorage.RemoveAt(modificationIndex); + return true; + } + + return false; + } + + private static void DeleteDependentContent( + LauncherContentKey contentKey, + List addons, + List patches) + { + var dependentPatches = patches + .Where(patch => IsDependentOn(patch, contentKey)) + .ToList(); + + foreach (LauncherContent patch in dependentPatches) + { + DeleteDependentAddons(patch.ContentKey, addons); + patches.Remove(patch); + } + + DeleteDependentAddons(contentKey, addons); + } + + private static void DeleteDependentAddons( + LauncherContentKey parentKey, + List addons) + { + 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..f7cbfed6 --- /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..4b617b2b --- /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..b901ee09 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs @@ -0,0 +1,113 @@ +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.Name) || string.IsNullOrWhiteSpace(contentKey.Version)) + { + return null; + } + + return contentKey.ContentType switch + { + ModificationType.Addon when !string.IsNullOrWhiteSpace(contentKey.ParentIdentity) => ResolvePackagePath( + paths.ModsDirectory, + contentKey.ParentIdentity, + LauncherFileSystemLayout.AddonsFolderName, + contentKey.Name, + contentKey.Version), + ModificationType.Mod => ResolvePackagePath( + paths.ModsDirectory, + contentKey.Name, + contentKey.Version), + ModificationType.Patch when !string.IsNullOrWhiteSpace(contentKey.ParentIdentity) => ResolvePackagePath( + paths.ModsDirectory, + contentKey.ParentIdentity, + LauncherFileSystemLayout.PatchesFolderName, + contentKey.Name, + contentKey.Version), + _ => null + }; + } + + /// + /// Resolves the owned content-card directory, or returns for an incomplete identity. + /// + public static OwnedContentPath? ResolveContentPath( + LauncherPaths paths, + LauncherContentKey contentKey) + { + ArgumentNullException.ThrowIfNull(paths); + if (string.IsNullOrWhiteSpace(contentKey.Name)) + { + return null; + } + + return contentKey.ContentType switch + { + ModificationType.Addon when !string.IsNullOrWhiteSpace(contentKey.ParentIdentity) => ResolvePackagePath( + paths.ModsDirectory, + contentKey.ParentIdentity, + LauncherFileSystemLayout.AddonsFolderName, + contentKey.Name), + ModificationType.Patch when !string.IsNullOrWhiteSpace(contentKey.ParentIdentity) => ResolvePackagePath( + paths.ModsDirectory, + contentKey.ParentIdentity, + LauncherFileSystemLayout.PatchesFolderName, + contentKey.Name), + ModificationType.Mod => ResolvePackagePath(paths.ModsDirectory, contentKey.Name), + _ => null + }; + } + + /// + /// Resolves the owned cleanup root, or returns for an incomplete identity. + /// + public static OwnedContentPath? ResolveCleanupRootPath( + LauncherPaths paths, + LauncherContentKey contentKey) + { + ArgumentNullException.ThrowIfNull(paths); + if (string.IsNullOrWhiteSpace(contentKey.Name)) + { + return null; + } + + return contentKey.ContentType switch + { + ModificationType.Addon when !string.IsNullOrWhiteSpace(contentKey.ParentIdentity) => + ResolvePackagePath(paths.ModsDirectory, contentKey.ParentIdentity), + ModificationType.Patch when !string.IsNullOrWhiteSpace(contentKey.ParentIdentity) => + ResolvePackagePath(paths.ModsDirectory, contentKey.ParentIdentity), + ModificationType.Mod => ResolvePackagePath(paths.ModsDirectory, 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/Remote/IRemoteConnectionProbe.cs b/GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs new file mode 100644 index 00000000..ccb46b58 --- /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..9e2cbb66 --- /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 +{ + /// + /// Occurs after launcher preferences have changed. + /// + event EventHandler? PreferencesChanged; + + /// + /// Gets the current launcher preferences. + /// + LauncherPreferences Current { get; } + + /// + /// Persists the supplied launcher preferences and publishes the updated state. + /// + /// + /// 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..b07bbc5d --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherGamePreferences.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; + +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 IReadOnlyList CustomGameClients { get; init; } = + Array.Empty(); + + public IReadOnlyList CustomWorldBuilders { get; init; } = + Array.Empty(); +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherGamePreferencesSet.cs b/GenLauncherGO.Core/Settings/Models/LauncherGamePreferencesSet.cs new file mode 100644 index 00000000..db51c35a --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherGamePreferencesSet.cs @@ -0,0 +1,33 @@ +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 game switch + { + SupportedGame.Generals => Generals, + SupportedGame.ZeroHour => ZeroHour, + _ => throw new ArgumentOutOfRangeException(nameof(game), game, "A supported game is required."), + }; + } + + 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 new ArgumentOutOfRangeException(nameof(game), game, "A supported game is required."), + }; + } +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherInstallations.cs b/GenLauncherGO.Core/Settings/Models/LauncherInstallations.cs new file mode 100644 index 00000000..89695640 --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherInstallations.cs @@ -0,0 +1,57 @@ +using System; +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 game switch + { + SupportedGame.Generals => Generals, + SupportedGame.ZeroHour => ZeroHour, + _ => throw new ArgumentOutOfRangeException(nameof(game), game, "A supported game is required."), + }; + } + + public LauncherInstallations WithPath(SupportedGame game, string? path) + { + return game switch + { + SupportedGame.Generals => this with { Generals = path }, + SupportedGame.ZeroHour => this with { ZeroHour = path }, + _ => throw new ArgumentOutOfRangeException(nameof(game), game, "A supported game is required."), + }; + } + + /// + /// 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..5c990c8b --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherSharedPreferences.cs @@ -0,0 +1,10 @@ +namespace GenLauncherGO.Core.Settings.Models; + +public sealed record LauncherSharedPreferences +{ + public bool AutoDeleteOldVersions { get; init; } + + public bool HideLauncherAfterGameStart { get; init; } + + public bool UseEnglishLanguage { get; init; } +} diff --git a/GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs b/GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs new file mode 100644 index 00000000..d74f6f0b --- /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..ccd9c8ba --- /dev/null +++ b/GenLauncherGO.Core/Startup/Contracts/IGameInstallationService.cs @@ -0,0 +1,87 @@ +using System; +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 one installation root, including its relationship to the launcher executable directory. + /// + 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); + + bool hasInvalidNonemptyPath = + (!String.IsNullOrWhiteSpace(installations.Generals) && !generals.IsValid) || + (!String.IsNullOrWhiteSpace(installations.ZeroHour) && !zeroHour.IsValid); + bool hasDuplicatePath = generals is { IsValid: true, CanonicalPath: not null } && + zeroHour is { IsValid: true, CanonicalPath: not null } && + String.Equals( + generals.CanonicalPath, + zeroHour.CanonicalPath, + StringComparison.OrdinalIgnoreCase); + 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..881dfb87 --- /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..23c6b966 --- /dev/null +++ b/GenLauncherGO.Core/Startup/Contracts/ILauncherSingleInstanceGuard.cs @@ -0,0 +1,14 @@ +using System; + +namespace GenLauncherGO.Core.Startup.Contracts; + +/// +/// Represents ownership of the launcher single-instance guard. +/// +public interface ILauncherSingleInstanceGuard : IDisposable +{ + /// + /// Gets a value indicating whether the guard was acquired by the current process. + /// + bool IsAcquired { get; } +} diff --git a/GenLauncherGO.Core/Startup/ILauncherPathResolver.cs b/GenLauncherGO.Core/Startup/ILauncherPathResolver.cs new file mode 100644 index 00000000..db8d9a12 --- /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..68c111f7 --- /dev/null +++ b/GenLauncherGO.Core/Startup/LauncherFileSystemLayout.cs @@ -0,0 +1,128 @@ +using System; +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 BinkLibraryFileName = "BINKW32.DLL"; + + public const string ZeroHourWindowArchiveFileName = "WindowZH.big"; + + public const string GeneralsWindowArchiveFileName = "Window.big"; + + public const string ZeroHourCommunityExecutableFileName = "generalszh.exe"; + + public const string GeneralsCommunityExecutableFileName = "generalsv.exe"; + + public const string GeneralsOnlineExecutableFileName = "generalsonlinezh.exe"; + + public const string ZeroHourCommunityWorldBuilderExecutableFileName = "worldbuilderzh.exe"; + + public const string GeneralsCommunityWorldBuilderExecutableFileName = "worldbuilderv.exe"; + + public const string VanillaWorldBuilderExecutableFileName = "WorldBuilder.exe"; + + /// + /// 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 community game executable for a managed game. + /// + public static string GetCommunityGameExecutableName(SupportedGame managedGame) + { + return managedGame switch + { + SupportedGame.Generals => GeneralsCommunityExecutableFileName, + SupportedGame.ZeroHour => ZeroHourCommunityExecutableFileName, + _ => throw new ArgumentOutOfRangeException( + nameof(managedGame), + managedGame, + "A supported game is required."), + }; + } + + /// + /// Gets the community World Builder executable for a managed game. + /// + public static string GetCommunityWorldBuilderExecutableName(SupportedGame managedGame) + { + return managedGame switch + { + SupportedGame.Generals => GeneralsCommunityWorldBuilderExecutableFileName, + SupportedGame.ZeroHour => ZeroHourCommunityWorldBuilderExecutableFileName, + _ => throw new ArgumentOutOfRangeException( + nameof(managedGame), + managedGame, + "A supported game is required."), + }; + } + + /// + /// Gets the launcher-owned per-title directory name for a supported game. + /// + internal static string GetGameDataFolderName(SupportedGame managedGame) + { + return managedGame switch + { + SupportedGame.Generals => GeneralsDataFolderName, + SupportedGame.ZeroHour => ZeroHourDataFolderName, + _ => throw new ArgumentOutOfRangeException( + nameof(managedGame), + managedGame, + "A supported game is required."), + }; + } +} diff --git a/GenLauncherGO.Core/Startup/LauncherPaths.cs b/GenLauncherGO.Core/Startup/LauncherPaths.cs new file mode 100644 index 00000000..f08f3f3d --- /dev/null +++ b/GenLauncherGO.Core/Startup/LauncherPaths.cs @@ -0,0 +1,161 @@ +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 +{ + internal LauncherPaths(SupportedGame game, string gameDirectory, string ownedGameDataDirectory) + { + if (game is not SupportedGame.Generals and not SupportedGame.ZeroHour) + { + throw new ArgumentOutOfRangeException(nameof(game), game, "A supported game is required."); + } + + Game = game; + GameDirectory = LexicalPath.NormalizeFullPath(gameDirectory); + OwnedGameDataDirectory = LexicalPath.NormalizeFullPath(ownedGameDataDirectory); + } + + private const string LauncherDataFileName = "LauncherData.yaml"; + + 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 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; + string temporaryPath = LexicalPath.ResolvePath(temporaryPackagesRoot, relativePath); + if (!LexicalPath.IsPathInDirectory(temporaryPath, temporaryPackagesRoot)) + { + throw new ArgumentException( + "The installed package path cannot be staged outside the launcher temporary package folder.", + nameof(installedFolderPath)); + } + + return temporaryPath; + } + + /// + /// 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 backupRoot = Path.Combine(StateDirectory, LauncherFileSystemLayout.PackageBackupsFolderName); + string backupPath = ResolveOwnedPath(backupRoot, relativePath); + return new OwnedContentPath(backupRoot, backupPath); + } + + // Canonicalize before checking containment so relative traversal cannot escape the launcher-owned root. + private static string ResolveOwnedPath(string rootDirectory, string relativePath) + { + string candidatePath = LexicalPath.ResolvePath(rootDirectory, relativePath); + if (!LexicalPath.IsPathInDirectory(candidatePath, rootDirectory)) + { + throw new ArgumentException("The resolved path must stay inside the launcher-owned directory."); + } + + return candidatePath; + } +} diff --git a/GenLauncherGO.Core/Startup/LauncherRuntimePathContext.cs b/GenLauncherGO.Core/Startup/LauncherRuntimePathContext.cs new file mode 100644 index 00000000..8b0d8880 --- /dev/null +++ b/GenLauncherGO.Core/Startup/LauncherRuntimePathContext.cs @@ -0,0 +1,64 @@ +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); + + if (paths.Game is not SupportedGame.Generals and not SupportedGame.ZeroHour) + { + throw new ArgumentOutOfRangeException( + nameof(paths), + paths.Game, + "The active path set must represent a supported game."); + } + + string expectedDataDirectory = LexicalPath.NormalizeFullPath( + StoragePaths.GetGameDataDirectory(paths.Game)); + if (!string.Equals( + paths.OwnedGameDataDirectory, + expectedDataDirectory, + StringComparison.OrdinalIgnoreCase)) + { + 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..c9064390 --- /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..c65a7431 --- /dev/null +++ b/GenLauncherGO.Core/Startup/Models/GameInstallationLocation.cs @@ -0,0 +1,23 @@ +using System; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Core.Startup.Models; + +public sealed record GameInstallationLocation +{ + public GameInstallationLocation(SupportedGame game, string directory) + { + if (game is not SupportedGame.Generals and not SupportedGame.ZeroHour) + { + throw new ArgumentOutOfRangeException(nameof(game), game, "A supported game is required."); + } + + 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..b8b6d77c --- /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 directory does not contain the canonical files required by the selected game. + /// + RequiredFilesMissing = 3, + + /// + /// The launcher executable directory is the game installation or one of its descendants. + /// + LauncherLocationOverlapsGame = 4, + + /// + /// The path traverses a reparse point and is unsafe for launcher mutations. + /// + UnsafeFileSystemPath = 5, + + /// + /// Windows could not safely resolve or inspect the directory. + /// + PathUnavailable = 6, +} diff --git a/GenLauncherGO.Core/Startup/Models/GameInstallationValidationResult.cs b/GenLauncherGO.Core/Startup/Models/GameInstallationValidationResult.cs new file mode 100644 index 00000000..d2ab0d54 --- /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..84f3fefd --- /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..956399a2 --- /dev/null +++ b/GenLauncherGO.Core/Startup/Models/LauncherRestartResult.cs @@ -0,0 +1,25 @@ +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(succeeded: true, errorMessage: null); + + public static LauncherRestartResult Failure(string errorMessage) + { + System.ArgumentException.ThrowIfNullOrWhiteSpace(errorMessage); + return new LauncherRestartResult(succeeded: false, errorMessage); + } +} diff --git a/GenLauncherGO.Core/Startup/SupportedGame.cs b/GenLauncherGO.Core/Startup/SupportedGame.cs new file mode 100644 index 00000000..6cc311f0 --- /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..3037a1de --- /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..66defda3 --- /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..9f6ddaae --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadPauseController.cs @@ -0,0 +1,75 @@ +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 object _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); + } + } +} diff --git a/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs b/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs new file mode 100644 index 00000000..fabe0e74 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs @@ -0,0 +1,53 @@ +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 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 ?? string.Empty); + } + + /// + /// Creates an unexpected failure result while preserving diagnostic detail. + /// + public static PackageDownloadResult UnexpectedFailure(string message) + { + return new PackageDownloadResult( + PackageDownloadStatus.UnexpectedFailure, + message ?? string.Empty); + } +} diff --git a/GenLauncherGO.Core/Updating/Models/PackageDownloadStatus.cs b/GenLauncherGO.Core/Updating/Models/PackageDownloadStatus.cs new file mode 100644 index 00000000..790cf42a --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadStatus.cs @@ -0,0 +1,27 @@ +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, + + /// + /// 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..0f9b23fa --- /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..3d3c32f1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/AGENTS.md @@ -0,0 +1,9 @@ +# 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 reparse-point 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. +- Document mutation effects, safety invariants, external compatibility, and Windows/platform quirks; omit routine private-member summaries. +- Infrastructure tests use isolated temporary directories and substituted external services, never a user's real game installation or credentials. diff --git a/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs b/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs new file mode 100644 index 00000000..b715a530 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs @@ -0,0 +1,115 @@ +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); + Directory.CreateDirectory(destinationRoot); + + _logger.LogInformation( + "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)) + { + Directory.CreateDirectory(entryDirectory); + } + + entry.WriteToFile(entryPath, new ExtractionOptions + { + Overwrite = true, + PreserveFileTime = true + }); + + extractedEntryCount++; + } + + _logger.LogInformation( + "Extracted {EntryCount} archive entries to {DestinationDirectory}", + extractedEntryCount, + Path.GetFileName(destinationRoot)); + } + + /// + /// Resolves the destination path for an archive entry and rejects paths outside the extraction root. + /// + /// + /// 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 destinationPath = LexicalPath.ResolvePath(destinationRoot, normalizedEntryKey); + if (convertBigFilesToGib) + { + destinationPath = BigFileVariantPath.GetInstalledPath(destinationPath); + } + + if (!LexicalPath.IsPathInDirectory(destinationPath, destinationRoot)) + { + throw new InvalidDataException($"Archive entry '{entryKey}' would extract outside the destination folder."); + } + + return destinationPath; + } +} diff --git a/GenLauncherGO.Infrastructure/Archives/ArchiveFileSupport.cs b/GenLauncherGO.Infrastructure/Archives/ArchiveFileSupport.cs new file mode 100644 index 00000000..ae80a448 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Archives/ArchiveFileSupport.cs @@ -0,0 +1,18 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Infrastructure.Archives; + +/// +/// Defines archive formats accepted consistently by legacy manual import and managed package updates. +/// +internal static class ArchiveFileSupport +{ + public static bool IsSupported(string filePath) + { + string extension = Path.GetExtension(filePath); + return string.Equals(extension, ".zip", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".rar", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".7z", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/GenLauncherGO.Infrastructure/Archives/Contracts/IArchiveExtractor.cs b/GenLauncherGO.Infrastructure/Archives/Contracts/IArchiveExtractor.cs new file mode 100644 index 00000000..703b3e21 --- /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..92e82665 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Common/BigFileVariantPath.cs @@ -0,0 +1,110 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Infrastructure.Common; + +/// +/// Resolves manifest, installed, deployment, and in-progress path variants for package .big files. +/// +internal static class BigFileVariantPath +{ + /// + /// The extension used by remote manifests and game deployment targets. + /// + public const string BigExtension = ".big"; + + /// + /// The extension used for launcher-managed installed package files. + /// + public const string GibExtension = ".gib"; + + /// + /// 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, BigExtension) + : filePath; + } + + /// + /// Returns the same-base .gib variant for a requested path. + /// + public static string GetGibVariantPath(string filePath) + { + return Path.ChangeExtension(filePath, 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), BigExtension, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsGibFilePath(string filePath) + { + return string.Equals(Path.GetExtension(filePath), GibExtension, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs b/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs new file mode 100644 index 00000000..6fc5cbdf --- /dev/null +++ b/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs @@ -0,0 +1,159 @@ +using System; +using System.IO; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Infrastructure.Common; + +/// +/// Provides shared filesystem path-safety checks for infrastructure services. +/// +internal static class FileSystemPathSafety +{ + /// + /// Resolves a candidate path and verifies that it stays within an owned root without traversing existing links. + /// + public static string ResolveOwnedSubpath( + string ownedRoot, + string candidatePath, + string outsideRootMessage, + string linkedPathMessage) + { + string normalizedRoot = LexicalPath.NormalizeFullPath(ownedRoot); + string normalizedCandidate = LexicalPath.NormalizeFullPath(candidatePath); + if (!LexicalPath.IsPathInDirectory(normalizedCandidate, normalizedRoot)) + { + throw new InvalidDataException(outsideRootMessage); + } + + EnsureExistingPathChainHasNoReparsePoints( + normalizedRoot, + "An owned root path must be rooted.", + linkedPathMessage); + EnsureExistingPathChainHasNoReparsePoints( + normalizedCandidate, + "An owned candidate path must be rooted.", + linkedPathMessage); + + return normalizedCandidate; + } + + /// + /// Rejects paths whose existing filesystem chain contains a reparse point. + /// + public static void EnsureExistingPathChainHasNoReparsePoints( + string path, + string unrootedPathMessage, + string linkedPathMessage) + { + if (ExistingPathChainContainsReparsePoint(path, unrootedPathMessage)) + { + throw new InvalidDataException(linkedPathMessage); + } + } + + /// + /// Rejects a directory tree whose root or child entries contain a reparse point. + /// + public static void EnsureDirectoryTreeHasNoReparsePoints( + string directoryPath, + string linkedPathMessage) + { + string rootPath = LexicalPath.NormalizeFullPath(directoryPath); + if (IsReparsePoint(rootPath)) + { + throw new InvalidDataException(linkedPathMessage); + } + + EnsureDirectoryChildrenHaveNoReparsePoints(rootPath, linkedPathMessage); + } + + /// + /// Determines whether an existing path chain contains a reparse point. + /// + public static bool ExistingPathChainContainsReparsePoint(string path, string unrootedPathMessage) + { + string fullPath = LexicalPath.NormalizeFullPath(path); + string root = Path.GetPathRoot(fullPath) + ?? throw new InvalidDataException(unrootedPathMessage); + string relativePath = LexicalPath.GetRelativePath(root, fullPath); + if (relativePath == ".") + { + return false; + } + + string currentPath = root; + string[] segments = relativePath.Split( + new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + StringSplitOptions.RemoveEmptyEntries); + foreach (string segment in segments) + { + currentPath = Path.Combine(currentPath, segment); + if (!TryGetAttributes(currentPath, out FileAttributes attributes)) + { + return false; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + return true; + } + } + + return false; + } + + private 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; + } + } + + /// + /// 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 void EnsureDirectoryChildrenHaveNoReparsePoints( + string directoryPath, + string linkedPathMessage) + { + foreach (string entryPath in Directory.EnumerateFileSystemEntries(directoryPath)) + { + FileAttributes attributes = File.GetAttributes(entryPath); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidDataException(linkedPathMessage); + } + + if ((attributes & FileAttributes.Directory) != 0) + { + EnsureDirectoryChildrenHaveNoReparsePoints(entryPath, linkedPathMessage); + } + } + } +} diff --git a/GenLauncherGO.Infrastructure/Common/ManifestPathResolver.cs b/GenLauncherGO.Infrastructure/Common/ManifestPathResolver.cs new file mode 100644 index 00000000..7d32fbad --- /dev/null +++ b/GenLauncherGO.Infrastructure/Common/ManifestPathResolver.cs @@ -0,0 +1,135 @@ +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); + + string normalizedFileName = NormalizeRelativePath(manifestFileName); + string rootPath = LexicalPath.NormalizeFullPath(rootDirectory); + string candidatePath = LexicalPath.ResolvePath(rootPath, normalizedFileName); + + if (!LexicalPath.IsPathInDirectory(candidatePath, rootPath)) + { + throw new InvalidDataException( + $"Manifest file '{manifestFileName}' would resolve outside the package directory."); + } + + return candidatePath; + } + + /// + /// 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(), + useDeploymentErrors: false, + 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 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, + useDeploymentErrors: true, + nameof(relativePath))); + } + + private static string NormalizeRelativePathCore( + string relativePath, + bool useDeploymentErrors, + string parameterName) + { + if (Path.IsPathRooted(relativePath) || + relativePath.Contains(':', StringComparison.Ordinal)) + { + throw CreateValidationException( + useDeploymentErrors, + "Manifest file paths must be relative.", + "Deployment manifest paths must be relative.", + parameterName); + } + + string[] segments = relativePath.Split( + new[] { '/', '\\' }, + StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + { + throw CreateValidationException( + useDeploymentErrors, + "Manifest file paths must include a file name.", + "Deployment manifest paths must include a file name.", + parameterName); + } + + foreach (string segment in segments) + { + if (string.Equals(segment, ".", StringComparison.Ordinal) || + string.Equals(segment, "..", StringComparison.Ordinal)) + { + throw CreateValidationException( + useDeploymentErrors, + "Manifest file paths must not contain current or parent directory segments.", + "Deployment manifest paths must not contain parent directory segments.", + parameterName); + } + } + + return Path.Combine(segments); + } + + private static Exception CreateValidationException( + bool useDeploymentErrors, + string manifestMessage, + string deploymentMessage, + string parameterName) + { + return useDeploymentErrors + ? new InvalidDataException(deploymentMessage) + : new ArgumentException(manifestMessage, parameterName); + } +} diff --git a/GenLauncherGO.Infrastructure/Common/OwnedDirectoryTree.cs b/GenLauncherGO.Infrastructure/Common/OwnedDirectoryTree.cs new file mode 100644 index 00000000..68ca5289 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Common/OwnedDirectoryTree.cs @@ -0,0 +1,381 @@ +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 (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 must be rooted.", + "Owned directory paths must not cross reparse points."); + 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 (!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 (!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 (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 must be rooted.", + "Owned directory roots must not contain reparse points."); + + var deletedDirectories = new List(); + DirectoryInfo? current = Directory.GetParent(normalizedChild); + while (current is not null && + !string.Equals(current.FullName, normalizedRoot, StringComparison.OrdinalIgnoreCase)) + { + string currentPath = current.FullName; + DirectoryInfo? parent = current.Parent; + if (!LexicalPath.IsPathInDirectory(currentPath, normalizedRoot)) + { + break; + } + + if (!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, recursive: false); + deletedDirectories.Add(currentPath); + current = parent; + } + + 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); + } + + /// + /// 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 must be rooted.", + "Owned directory roots must not contain reparse points."); + + string? parentDirectory = Path.GetDirectoryName(directoryPath); + if (!string.IsNullOrWhiteSpace(parentDirectory)) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + parentDirectory, + "Owned directory paths must be rooted.", + "Owned directory paths must not cross reparse points."); + } + } + + private static void DeleteDirectoryChildren(string directoryPath) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + directoryPath, + "Owned directory paths must be rooted.", + "Owned directory paths must not cross reparse points."); + + foreach (string entryPath in Directory.EnumerateFileSystemEntries(directoryPath).ToList()) + { + if (!TryGetAttributes(entryPath, out FileAttributes attributes)) + { + continue; + } + + DeleteEntryWithoutFollowing(entryPath, attributes); + } + } + + private static bool DeleteEmptyDirectoriesCore(string directoryPath) + { + if (!TryGetAttributes(directoryPath, out FileAttributes attributes) || + (attributes & FileAttributes.ReparsePoint) != 0 || + (attributes & FileAttributes.Directory) == 0) + { + return false; + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + directoryPath, + "Owned directory paths must be rooted.", + "Owned directory paths must not cross reparse points."); + + foreach (string entryPath in Directory.EnumerateDirectories(directoryPath).ToList()) + { + if (!TryGetAttributes(entryPath, out FileAttributes childAttributes) || + (childAttributes & FileAttributes.ReparsePoint) != 0) + { + continue; + } + + DeleteEmptyDirectoriesCore(entryPath); + } + + if (Directory.EnumerateFileSystemEntries(directoryPath).Any()) + { + return false; + } + + Directory.Delete(directoryPath, recursive: false); + return true; + } + + private static void DeleteReparsePointsCore(string directoryPath, List deletedPaths) + { + if (!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 must be rooted.", + "Owned directory paths must not cross reparse points."); + + foreach (string entryPath in Directory.EnumerateFileSystemEntries(directoryPath).ToList()) + { + if (!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 (!TryGetAttributes(entryPath, out FileAttributes currentAttributes)) + { + return; + } + + if ((currentAttributes & FileAttributes.ReparsePoint) != 0) + { + DeleteLinkEntry(entryPath, currentAttributes); + return; + } + + DeleteDirectoryChildren(entryPath); + Directory.Delete(entryPath, recursive: false); + return; + } + + File.Delete(entryPath); + } + + private static void DeleteLinkEntry(string entryPath, FileAttributes attributes) + { + if ((attributes & FileAttributes.Directory) != 0) + { + Directory.Delete(entryPath, recursive: false); + } + else + { + File.Delete(entryPath); + } + } + + private 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; + } + } +} diff --git a/GenLauncherGO.Infrastructure/Common/PhysicalDirectoryPath.cs b/GenLauncherGO.Infrastructure/Common/PhysicalDirectoryPath.cs new file mode 100644 index 00000000..10a5a6f2 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Common/PhysicalDirectoryPath.cs @@ -0,0 +1,164 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace GenLauncherGO.Infrastructure.Common; + +/// +/// Identifies an existing directory independently of aliases in its path spelling. +/// +internal readonly record struct PhysicalDirectoryIdentity(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 PhysicalDirectoryIdentity GetIdentity(string path) + { + using SafeFileHandle handle = OpenDirectory(path); + if (!GetFileInformationByHandle(handle, out ByHandleFileInformation information)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + ulong fileIndex = ((ulong)information.FileIndexHigh << 32) | information.FileIndexLow; + return new PhysicalDirectoryIdentity(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."); + } + + SafeFileHandle handle = CreateFile( + fullPath, + FileReadAttributes, + FileShareRead | FileShareWrite | FileShareDelete, + IntPtr.Zero, + OpenExisting, + FileFlagBackupSemantics, + IntPtr.Zero); + if (handle.IsInvalid) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error); + } + + return handle; + } + + 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)); + } + + [StructLayout(LayoutKind.Sequential)] + private struct ByHandleFileInformation + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [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); +} 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..97e6a317 --- /dev/null +++ b/GenLauncherGO.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -0,0 +1,77 @@ +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(); + 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..dc8952b3 --- /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..86ec42d5 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs @@ -0,0 +1,482 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.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 ILogger _logger; + + private readonly IAtomicFileWriter _atomicFileWriter; + + 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); + + 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 = new(); + foreach (ContentIntegrityTarget target in targets) + { + cancellationToken.ThrowIfCancellationRequested(); + + ContentIntegritySnapshotDocument? snapshot; + try + { + snapshot = await snapshotStore.ReadSnapshotAsync(target.Id, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or JsonException or InvalidDataException) + { + _logger.LogError( + exception, + "Failed to read integrity snapshot for {TargetName}.", + target.DisplayName); + issues.Add(CreateIssue( + target, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + ".", + exception.Message)); + continue; + } + + if (snapshot is null || snapshot.SourceKind != target.SourceKind) + { + _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(CreateIssue( + target, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + ".", + exception.Message)); + } + + continue; + } + + try + { + ContentIntegrityScanResult scan = + await ContentIntegrityScanner.ScanAsync(target, cancellationToken).ConfigureAwait(false); + AddScanIssues(target, snapshot, scan, issues); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or InvalidDataException) + { + _logger.LogError( + exception, + "Failed to verify content for {TargetName}.", + target.DisplayName); + issues.Add(CreateIssue( + target, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + ".", + exception.Message)); + } + } + + if (targets.Count > 0) + { + _logger.LogInformation( + "Completed content integrity verification for {TargetCount} target(s); issues: {IssueCount}.", + targets.Count, + 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); + int deleteIssueCount = report.Issues.Count(issue => issue.Action == IntegrityIssueAction.Delete); + + _logger.LogInformation( + "Applying content integrity cleanup for {DeleteIssueCount} delete issue(s).", + deleteIssueCount); + + foreach (ContentIntegrityIssue issue in report.Issues.Where(issue => + issue.Action == IntegrityIssueAction.Delete)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!targetIndex.TryGetValue(issue.TargetId, out ContentIntegrityTarget? target)) + { + throw new InvalidDataException("The cleanup report references an unknown integrity target."); + } + + string path = ContentIntegrityPath.ResolveRelativePath(target.RootDirectory, issue.RelativePath); + string? parentDirectory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(parentDirectory)) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + parentDirectory, + "Integrity cleanup paths must be rooted.", + "Integrity cleanup paths must not cross reparse points."); + } + + DeleteEntry(path, issue, target); + } + + foreach (ContentIntegrityTarget target in targets.Where(target => + report.Issues.Any(issue => + issue.TargetId == target.Id && + issue.Action == IntegrityIssueAction.Delete))) + { + DeleteUnexpectedEmptyDirectories(target, cancellationToken); + } + + _logger.LogInformation( + "Completed content integrity cleanup for {DeleteIssueCount} delete issue(s).", + deleteIssueCount); + 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(CreateIssue( + target, + IntegrityIssueKind.MissingFile, + GetManagedOrManualAction(target.SourceKind, IntegrityIssueKind.MissingFile), + expected.RelativePath, + expectedSizeBytes: expected.Size)); + continue; + } + + if (current.Size != expected.Size || + !string.Equals(current.Sha256, expected.Sha256, StringComparison.OrdinalIgnoreCase)) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.ModifiedFile, + GetManagedOrManualAction(target.SourceKind, IntegrityIssueKind.ModifiedFile), + expected.RelativePath, + expectedSizeBytes: expected.Size)); + } + } + + foreach (ContentIntegrityScannedFile current in scan.Files.Values) + { + if (!expectedFiles.ContainsKey(current.RelativePath)) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.UnexpectedFile, + GetManagedOrManualAction(target.SourceKind, IntegrityIssueKind.UnexpectedFile), + current.RelativePath)); + } + } + + HashSet expectedEmptyDirectories = + new(snapshot.EmptyDirectories, StringComparer.OrdinalIgnoreCase); + foreach (string directory in scan.EmptyDirectories) + { + if (!expectedEmptyDirectories.Contains(directory)) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.EmptyDirectory, + GetManagedOrManualAction(target.SourceKind, IntegrityIssueKind.EmptyDirectory), + directory)); + } + } + + AddScanSafetyIssues(target, scan, issues); + } + + private static void AddScanSafetyIssues( + ContentIntegrityTarget target, + ContentIntegrityScanResult scan, + List issues) + { + foreach (string unsafeLink in scan.UnsafeLinks) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.UnsafeLink, + target.SourceKind is ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile + ? IntegrityIssueAction.Delete + : IntegrityIssueAction.Block, + unsafeLink)); + } + + foreach (ContentIntegrityScanError error in scan.Errors) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + error.RelativePath, + error.Message)); + } + } + + 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 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) + { + if (sourceKind == ContentSourceKind.Manual) + { + return IntegrityIssueAction.Absorb; + } + + if (sourceKind == ContentSourceKind.ManagedSingleFile) + { + return issueKind is IntegrityIssueKind.UnexpectedFile or IntegrityIssueKind.EmptyDirectory + ? IntegrityIssueAction.Delete + : IntegrityIssueAction.Redownload; + } + + if (sourceKind == ContentSourceKind.ManagedS3) + { + return issueKind is IntegrityIssueKind.UnexpectedFile or IntegrityIssueKind.EmptyDirectory + ? IntegrityIssueAction.Delete + : IntegrityIssueAction.Repair; + } + + return IntegrityIssueAction.TrustAsManual; + } + + /// + /// Deletes one confirmed managed-content entry without following links. + /// + private void DeleteEntry( + string path, + ContentIntegrityIssue issue, + ContentIntegrityTarget target) + { + FileAttributes attributes; + try + { + attributes = File.GetAttributes(path); + } + catch (FileNotFoundException) + { + return; + } + catch (DirectoryNotFoundException) + { + return; + } + + if ((attributes & FileAttributes.Directory) != 0) + { + Directory.Delete(path, recursive: false); + } + else + { + File.Delete(path); + } + + _logger.LogInformation( + "Deleted confirmed unexpected integrity entry {RelativePath} from {TargetName}.", + issue.RelativePath, + target.DisplayName); + } + + 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.LogInformation( + "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..916f75a0 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityPath.cs @@ -0,0 +1,47 @@ +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) + { + string candidate = LexicalPath.ResolvePath(root, relativePath); + if (!LexicalPath.IsPathInDirectory(candidate, root)) + { + throw new InvalidDataException("An integrity issue path resolved outside its target root."); + } + + return candidate; + } + + /// + /// 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..74aea7d1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Infrastructure.Integrity.Support; + +/// +/// Scans content integrity targets without following reparse points. +/// +internal static class ContentIntegrityScanner +{ + /// + /// Scans one target without following reparse points. + /// + public static async Task ScanAsync( + ContentIntegrityTarget target, + CancellationToken cancellationToken) + { + Dictionary files = new(StringComparer.OrdinalIgnoreCase); + List emptyDirectories = new(); + List unsafeLinks = new(); + List errors = new(); + + string root = 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 + { + if (!string.Equals(directory, root, StringComparison.OrdinalIgnoreCase) && + FileSystemPathSafety.IsReparsePoint(directory)) + { + unsafeLinks.Add(directoryRelativePath); + continue; + } + + var entries = Directory.EnumerateFileSystemEntries(directory).ToList(); + if (entries.Count == 0 && + !string.Equals(directory, root, StringComparison.OrdinalIgnoreCase) && + !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; + } + + FileInfo fileInfo = new(entry); + string sha256 = await ComputeSha256Async(fileInfo.FullName, cancellationToken) + .ConfigureAwait(false); + files[relativePath] = new ContentIntegrityScannedFile(relativePath, fileInfo.Length, sha256); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + errors.Add(new ContentIntegrityScanError(directoryRelativePath, exception.Message)); + } + } + + return new ContentIntegrityScanResult(files, emptyDirectories, unsafeLinks, errors); + } + + /// + /// Determines whether a completed scan exactly matches an expected safe file set. + /// + 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 scan.Files.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase) + .SetEquals(normalizedExpectedPaths); + } + + private static async Task ComputeSha256Async( + string filePath, + CancellationToken cancellationToken) + { + await using FileStream stream = new( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 1024 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + byte[] hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false); + return Convert.ToHexString(hash); + } + +} + +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..79f6b1df --- /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/DeploymentLaunchPreparationService.cs b/GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs new file mode 100644 index 00000000..37023434 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs @@ -0,0 +1,125 @@ +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; +using Microsoft.Extensions.Logging; + +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; + + private readonly ILogger _logger; + + public DeploymentLaunchPreparationService( + FileSystemDeploymentService deploymentEngine, + ILogger logger) + { + _deploymentEngine = deploymentEngine ?? throw new ArgumentNullException(nameof(deploymentEngine)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + 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); + LogDeploymentFailures("prepare launch content", result); + return result.Succeeded; + } + + public bool Cleanup( + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + + DeploymentResult result = _deploymentEngine.Cleanup(paths, cancellationToken); + LogDeploymentFailures("clean up launch content", result); + return result.Succeeded; + } + + public bool Recover( + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + + DeploymentResult result = _deploymentEngine.Recover(paths, cancellationToken); + LogDeploymentFailures("recover launch content", result); + return result.Succeeded; + } + + private IReadOnlyList CreateDeploymentPackages(LaunchPreparationRequest request) + { + return request.Versions + .Select((version, index) => CreateDeploymentPackage(request, version, index)) + .ToList(); + } + + private DeploymentPackage CreateDeploymentPackage( + LaunchPreparationRequest request, + LauncherContentVersion version, + int index) + { + ArgumentNullException.ThrowIfNull(version); + + return new DeploymentPackage(ResolvePackageRoot(request, version), index); + } + + private string ResolvePackageRoot( + LaunchPreparationRequest request, + LauncherContentVersion version) + { + return LauncherContentPathResolver.ResolveVersionPath(request.Paths, version.ContentKey)?.FullPath + ?? string.Empty; + } + + private void LogDeploymentFailures(string operationName, DeploymentResult result) + { + if (result.Succeeded) + { + return; + } + + foreach (DeploymentFailure failure in result.Failures) + { + _logger.LogError( + "Failed to {OperationName}. Kind: {FailureKind}; Path: {Path}; Message: {Message}", + operationName, + failure.Kind, + failure.Path, + failure.Message); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs new file mode 100644 index 00000000..6d70d8d0 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs @@ -0,0 +1,1138 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +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; + +/// +/// Deploys selected package files into the game directory and persists enough manifest state to undo the deployment. +/// +internal sealed class FileSystemDeploymentService +{ + private const int FileBufferSize = 1024 * 128; + + private readonly IHardLinkCreator _hardLinkCreator; + + private readonly ILogger _logger; + + private readonly DeploymentStateStore _stateStore; + + public FileSystemDeploymentService( + IHardLinkCreator hardLinkCreator, + ILogger logger) + { + _hardLinkCreator = hardLinkCreator ?? throw new ArgumentNullException(nameof(hardLinkCreator)); + _logger = logger ?? throw new ArgumentNullException(nameof(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(); + + try + { + using FileStream deploymentLock = DeploymentStateStore.AcquireDeploymentLock(paths); + return PrepareWithLock( + paths, + packages, + normalizedDisabledTargetRelativePaths, + cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Deployment preparation failed before deployment recovery could run."); + return DeploymentResult.Failure( + new[] + { + new DeploymentFailure( + DeploymentFailureKind.FileSystem, + paths.GameDirectory, + ex.Message), + }); + } + } + + /// + /// Cleans the active deployment from the game directory. + /// + public DeploymentResult Cleanup(LauncherPaths paths, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + + try + { + using FileStream deploymentLock = DeploymentStateStore.AcquireDeploymentLock(paths); + return CleanupCore(paths, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Deployment cleanup failed."); + return DeploymentResult.Failure( + new[] + { + new DeploymentFailure( + DeploymentFailureKind.FileSystem, + paths.GameDirectory, + ex.Message), + }); + } + } + + /// + /// Recovers interrupted deployment work from the persisted manifest or journal. + /// + public DeploymentResult Recover(LauncherPaths paths, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + + try + { + using FileStream deploymentLock = DeploymentStateStore.AcquireDeploymentLock(paths); + return RecoverCore(paths, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Deployment recovery failed."); + return DeploymentResult.Failure( + new[] + { + new DeploymentFailure( + DeploymentFailureKind.Manifest, + paths.GameDirectory, + ex.Message), + }); + } + } + + /// + /// Prepares a deployment while the deployment operation lock is held. + /// + private DeploymentResult PrepareWithLock( + LauncherPaths launcherPaths, + IReadOnlyList packages, + IReadOnlyList disabledTargetRelativePaths, + CancellationToken cancellationToken) + { + try + { + DeploymentResult cleanupResult = CleanupCore(launcherPaths, 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); + } + + string gameRoot = PhysicalDirectoryPath.ResolveExisting(launcherPaths.GameDirectory); + string gameRootIdentity = DeploymentStateStore.GetGameRootIdentity(gameRoot); + DeploymentStateStore.AppendJournal( + paths.JournalPath, + DeploymentJournalRecord.DeploymentStarted( + deploymentId, + gameRoot, + gameRootIdentity, + launcherPaths.Game)); + + IReadOnlyList files = + DeploymentFilePlanner.ResolveDeploymentFiles(packages); + 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, + deploymentId, + deployedTargetPaths, + backedUpTargetPaths, + entries, + cancellationToken); + + foreach (ResolvedDeploymentFile file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + + string targetPath = DeploymentPathResolver.ResolveGamePath(launcherPaths, file.TargetRelativePath); + EnsureSafeGameMutationPath(launcherPaths, targetPath); + string targetDirectory = Path.GetDirectoryName(targetPath) ?? launcherPaths.GameDirectory; + foreach (string directory in DeploymentFilePlanner.GetDirectoriesToCreate( + launcherPaths.GameDirectory, + targetDirectory)) + { + if (!Directory.Exists(directory)) + { + EnsureSafeGameMutationPath(launcherPaths, directory); + string relativeDirectory = DeploymentPathResolver.ToRelativeManifestPath( + launcherPaths.GameDirectory, + directory); + DeploymentStateStore.AppendJournal( + paths.JournalPath, + DeploymentJournalRecord.DirectoryCreated(relativeDirectory)); + Directory.CreateDirectory(directory); + EnsureSafeGameMutationPath(launcherPaths, directory); + createdDirectories.Add(relativeDirectory); + } + } + + EnsureSafeGameMutationPath(launcherPaths, targetPath); + DeploymentBackupDocument? backup; + if (!backedUpTargetPaths.TryGetValue(file.TargetRelativePath, out backup) && + File.Exists(targetPath)) + { + backup = BackupTargetFile( + paths, + deploymentId, + file.TargetRelativePath, + targetPath); + backedUpTargetPaths[file.TargetRelativePath] = backup; + } + + DeploymentFileFingerprint sourceFingerprint = ComputeFileFingerprint(file.SourcePath); + string stagingPath = CreateSiblingStagingPath(targetPath, deploymentId, "deploy"); + string stagingRelativePath = DeploymentPathResolver.ToRelativeManifestPath( + launcherPaths.GameDirectory, + stagingPath); + DeploymentStateStore.AppendJournal(paths.JournalPath, DeploymentJournalRecord.FileDeploymentStarted( + file.TargetRelativePath, + backup?.RelativePath, + sourceFingerprint, + backup?.Fingerprint, + stagingRelativePath)); + + EnsureSafeGameMutationPath(launcherPaths, targetPath); + (DeploymentMethod Method, DeploymentFileFingerprint Fingerprint) deployment = DeployFile( + file.SourcePath, + targetPath, + stagingPath, + sourceFingerprint); + DeploymentStateStore.AppendJournal(paths.JournalPath, DeploymentJournalRecord.FileDeployed( + file.TargetRelativePath, + deployment.Method, + backup?.RelativePath, + deployment.Fingerprint, + backup?.Fingerprint, + stagingRelativePath)); + cancellationToken.ThrowIfCancellationRequested(); + + entries.Add(new DeploymentFileDocument( + file.TargetRelativePath, + deployment.Method, + backup?.RelativePath, + deployment.Fingerprint, + backup?.Fingerprint, + stagingRelativePath, + backup?.StagingRelativePath)); + } + + DeploymentManifestDocument document = new( + SchemaVersion: DeploymentStateStore.CurrentSchemaVersion, + deploymentId, + entries, + createdDirectories.OrderByDescending(path => path.Length).ToList(), + gameRoot, + gameRootIdentity, + launcherPaths.Game); + cancellationToken.ThrowIfCancellationRequested(); + DeploymentStateStore.WriteManifest(paths.ActiveManifestPath, document); + _logger.LogInformation("Prepared deployment {DeploymentId} with {FileCount} file(s).", deploymentId, + entries.Count); + return DeploymentResult.Success(); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Deployment preparation was canceled; recovering any partial game-folder mutation."); + RecoverCore(launcherPaths, CancellationToken.None); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Deployment preparation failed."); + DeploymentFailure prepareFailure = new( + DeploymentFailureKind.FileSystem, + launcherPaths.GameDirectory, + ex.Message); + + DeploymentResult recoveryResult; + try + { + recoveryResult = RecoverCore(launcherPaths, 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()); + } + } + + /// + /// Cleans a deployment while the deployment operation lock is held. + /// + private DeploymentResult CleanupCore(LauncherPaths paths, CancellationToken cancellationToken) + { + DeploymentManifestDocument? manifest = RestoreActiveDeployment(paths, cancellationToken); + if (manifest is not null) + { + _logger.LogInformation("Cleaned deployment {DeploymentId}.", manifest.DeploymentId); + } + + return DeploymentResult.Success(); + } + + /// + /// Recovers deployment state while the deployment operation lock is held. + /// + private DeploymentResult RecoverCore(LauncherPaths paths, CancellationToken cancellationToken) + { + DeploymentManifestDocument? manifest = RestoreActiveDeployment(paths, cancellationToken); + if (manifest is not null) + { + _logger.LogInformation("Recovered deployment state for {DeploymentId}.", manifest.DeploymentId); + } + + 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, deploymentId: 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; + } + + /// + /// Deploys a file with a hard link first and copy fallback. + /// + private (DeploymentMethod Method, DeploymentFileFingerprint Fingerprint) DeployFile( + string sourcePath, + string targetPath, + string stagingPath, + DeploymentFileFingerprint expectedFingerprint) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + sourcePath, + "Deployment source paths must be rooted.", + "Deployment source paths must not contain reparse points."); + + if (File.Exists(targetPath)) + { + throw new IOException("A deployment target appeared after its original content was backed up."); + } + + DeploymentMethod method; + try + { + bool sourceIsReadOnly = (File.GetAttributes(sourcePath) & FileAttributes.ReadOnly) != 0; + if (!sourceIsReadOnly && + ArePathsOnSameVolume(sourcePath, stagingPath) && + _hardLinkCreator.TryCreateHardLink(stagingPath, sourcePath)) + { + method = DeploymentMethod.HardLink; + EnsureFingerprintMatches(stagingPath, expectedFingerprint, "The staged hard link did not match its source."); + } + else + { + if (File.Exists(stagingPath)) + { + throw new IOException("A deployment staging path was occupied unexpectedly."); + } + + DeploymentFileFingerprint copiedFingerprint = CopyFileAndFlush(sourcePath, stagingPath); + File.SetLastWriteTimeUtc(stagingPath, File.GetLastWriteTimeUtc(sourcePath)); + if (copiedFingerprint != expectedFingerprint) + { + throw new IOException("The source file changed while it was staged for deployment."); + } + + EnsureFingerprintMatches(stagingPath, expectedFingerprint, "The staged copy did not match its source."); + method = DeploymentMethod.Copy; + _logger.LogInformation( + "Hard-link deployment was unavailable for {FileName}; used a verified file copy.", + Path.GetFileName(targetPath)); + } + + File.Move(stagingPath, targetPath, overwrite: false); + EnsureFingerprintMatches( + targetPath, + expectedFingerprint, + "The deployed target did not match its staged content."); + return (method, expectedFingerprint); + } + catch + { + DeleteOwnedStagingFileIfExpected(stagingPath, expectedFingerprint, clearReadOnly: false); + throw; + } + } + + /// + /// Backs up deployment targets that should be hidden without deploying a replacement file. + /// + private void BackupDisabledTargets( + LauncherPaths launcherPaths, + IReadOnlyList disabledTargetRelativePaths, + DeploymentStatePaths deploymentPaths, + 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); + 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 = BackupTargetFile( + deploymentPaths, + deploymentId, + targetRelativePath, + targetPath); + backedUpTargetPaths[targetRelativePath] = backup; + + if (!deployedTargetPaths.Contains(targetRelativePath)) + { + entries.Add(new DeploymentFileDocument( + targetRelativePath, + DeploymentMethod.Copy, + backup.RelativePath, + DeployedFingerprint: null, + BackupFingerprint: backup.Fingerprint, + StagingRelativePath: null, + BackupStagingRelativePath: backup.StagingRelativePath)); + } + + _logger.LogInformation( + "Temporarily disabled base game file {FileName} for modded launch deployment.", + Path.GetFileName(targetPath)); + } + } + + /// + /// Journals intent and completion around committing a verified launcher-owned backup before removing a target. + /// + private static DeploymentBackupDocument BackupTargetFile( + DeploymentStatePaths deploymentPaths, + 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 must stay inside the deployment directory.", + "Deployment backup paths must not contain reparse points."); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath) ?? deploymentPaths.BackupDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + Path.GetDirectoryName(backupPath) ?? deploymentPaths.BackupDirectory, + "Deployment backup paths must be rooted.", + "Deployment backup paths must not contain reparse points."); + bool canMoveOriginal = ArePathsOnSameVolume(targetPath, backupPath); + string backupStagingPath = canMoveOriginal + ? string.Empty + : backupPath + $".partial-{Guid.NewGuid():N}"; + string backupStagingRelativePath = canMoveOriginal + ? string.Empty + : DeploymentPathResolver.ToRelativeManifestPath( + deploymentPaths.DeploymentDirectory, + backupStagingPath); + DeploymentStateStore.AppendJournal( + deploymentPaths.JournalPath, + DeploymentJournalRecord.FileBackupStarted( + targetRelativePath, + backupRelativePath, + backupStagingRelativePath)); + + DeploymentFileFingerprint backupFingerprint; + try + { + if (canMoveOriginal) + { + File.Move(targetPath, backupPath, overwrite: false); + backupFingerprint = ComputeFileFingerprint(backupPath); + } + else + { + backupFingerprint = CopyFileWithMetadataAndFlush(targetPath, backupStagingPath); + EnsureFingerprintMatches( + backupStagingPath, + backupFingerprint, + "The launcher-owned backup did not match the original game file."); + File.Move(backupStagingPath, backupPath, overwrite: false); + } + + EnsureFingerprintMatches( + backupPath, + backupFingerprint, + "The launcher-owned backup did not match the original game file."); + } + catch + { + if (!string.IsNullOrWhiteSpace(backupStagingPath)) + { + DeleteFileClearingReadOnly(backupStagingPath); + } + + throw; + } + + DeploymentStateStore.AppendJournal( + deploymentPaths.JournalPath, + DeploymentJournalRecord.FileBackedUp( + targetRelativePath, + backupRelativePath, + backupFingerprint, + backupStagingRelativePath)); + + if (!canMoveOriginal) + { + EnsureFingerprintMatches( + targetPath, + backupFingerprint, + "The original game file changed while its backup was being committed."); + DeleteFileClearingReadOnly(targetPath); + } + + return new DeploymentBackupDocument( + backupRelativePath, + backupFingerprint, + backupStagingRelativePath); + } + + private static string CreateBackupRelativePath(string deploymentId, string targetRelativePath) + { + return LexicalPath.NormalizeRelativePath(Path.Combine( + DeploymentStateStore.BackupsDirectoryName, + deploymentId, + targetRelativePath)); + } + + /// + /// Replays persisted deployment state to remove deployed files and restore original backups. + /// + private void CleanupManifest( + LauncherPaths paths, + DeploymentStatePaths deploymentPaths, + DeploymentManifestDocument manifest, + CancellationToken cancellationToken) + { + foreach (DeploymentFileDocument file in manifest.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + + string targetPath = DeploymentPathResolver.ResolveGamePath(paths, file.TargetRelativePath); + EnsureSafeGameMutationPath(paths, targetPath); + CleanupGameStagingFile( + paths, + file.StagingRelativePath, + file.DeployedFingerprint, + clearReadOnly: false); + CleanupGameStagingFile( + paths, + file.RestoreStagingRelativePath, + file.BackupFingerprint, + clearReadOnly: true); + CleanupBackupStagingFile(deploymentPaths, file.BackupStagingRelativePath); + + if (string.IsNullOrWhiteSpace(file.BackupRelativePath)) + { + if (File.Exists(targetPath)) + { + RequireExpectedTargetFingerprint( + targetPath, + file.DeployedFingerprint, + "A deployed game file was modified after launch preparation; cleanup left it untouched."); + DeleteDeployedTarget(targetPath, file); + DeploymentStateStore.AppendJournal( + deploymentPaths.JournalPath, + DeploymentJournalRecord.FileCleanupDeleted(file.TargetRelativePath)); + } + + continue; + } + + string backupPath = DeploymentPathResolver.ResolveDeploymentStatePath( + deploymentPaths.DeploymentDirectory, + file.BackupRelativePath); + backupPath = FileSystemPathSafety.ResolveOwnedSubpath( + deploymentPaths.DeploymentDirectory, + backupPath, + "Deployment backup paths must stay inside the deployment directory.", + "Deployment backup paths must not contain reparse points."); + 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 (File.Exists(targetPath)) + { + DeploymentFileFingerprint targetFingerprint = ComputeFileFingerprint(targetPath); + if (!backupExists && targetFingerprint == backupFingerprint) + { + continue; + } + + if (targetFingerprint != backupFingerprint && + (file.DeployedFingerprint is null || targetFingerprint != file.DeployedFingerprint)) + { + throw new InvalidDataException( + "A game file was modified after launch preparation; its original backup was preserved."); + } + } + + if (!backupExists) + { + throw new InvalidDataException( + "The original game-file backup is missing and the target is not already restored."); + } + + EnsureSafeGameMutationPath(paths, targetPath); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? paths.GameDirectory); + EnsureSafeGameMutationPath(paths, targetPath); + bool canMoveOriginal = ArePathsOnSameVolume(backupPath, targetPath); + string restoreStagingPath = canMoveOriginal + ? string.Empty + : CreateSiblingStagingPath(targetPath, manifest.DeploymentId, "restore"); + string restoreStagingRelativePath = canMoveOriginal + ? string.Empty + : DeploymentPathResolver.ToRelativeManifestPath( + paths.GameDirectory, + restoreStagingPath); + DeploymentStateStore.AppendJournal( + deploymentPaths.JournalPath, + DeploymentJournalRecord.FileCleanupRestoreStarted( + file.TargetRelativePath, + file.BackupRelativePath, + restoreStagingRelativePath)); + + try + { + if (File.Exists(targetPath)) + { + RequireExpectedRestoreTargetFingerprint( + targetPath, + file.DeployedFingerprint, + backupFingerprint, + "A deployed game file changed while its original was being restored."); + DeleteDeployedTarget(targetPath, file); + } + + if (canMoveOriginal) + { + File.Move(backupPath, targetPath, overwrite: false); + } + else + { + StageVerifiedFile(backupPath, restoreStagingPath, backupFingerprint); + File.Move(restoreStagingPath, targetPath, overwrite: false); + DeleteFileClearingReadOnly(backupPath); + } + + EnsureFingerprintMatches( + targetPath, + backupFingerprint, + "The restored game file did not match its launcher-owned backup."); + DeploymentStateStore.AppendJournal( + deploymentPaths.JournalPath, + DeploymentJournalRecord.FileCleanupRestored( + file.TargetRelativePath, + file.BackupRelativePath)); + } + catch + { + if (!string.IsNullOrWhiteSpace(restoreStagingPath)) + { + DeleteOwnedStagingFileIfExpected( + restoreStagingPath, + backupFingerprint, + clearReadOnly: true); + } + + throw; + } + } + + foreach (string relativeDirectory in manifest.CreatedDirectories.OrderByDescending(path => path.Length)) + { + cancellationToken.ThrowIfCancellationRequested(); + + string directoryPath = DeploymentPathResolver.ResolveGamePath(paths, relativeDirectory); + EnsureSafeGameMutationPath(paths, directoryPath); + if (!Directory.Exists(directoryPath)) + { + continue; + } + + if (!Directory.EnumerateFileSystemEntries(directoryPath).Any()) + { + Directory.Delete(directoryPath); + continue; + } + + _logger.LogInformation( + "Left deployment-created directory {DirectoryName} because it contains non-deployed files.", + Path.GetFileName(directoryPath)); + } + } + + private void StageVerifiedFile( + string sourcePath, + string stagingPath, + DeploymentFileFingerprint expectedFingerprint) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + sourcePath, + "Deployment source paths must be rooted.", + "Deployment source paths must not contain reparse points."); + + if (ArePathsOnSameVolume(sourcePath, stagingPath) && + _hardLinkCreator.TryCreateHardLink(stagingPath, sourcePath)) + { + EnsureFingerprintMatches( + stagingPath, + expectedFingerprint, + "The staged hard link did not match its source."); + return; + } + + 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."); + } + + EnsureFingerprintMatches( + stagingPath, + expectedFingerprint, + "The staged file copy did not match its source."); + } + + private static DeploymentFileFingerprint CopyFileAndFlush(string sourcePath, string destinationPath) + { + byte[] buffer = ArrayPool.Shared.Rent(FileBufferSize); + try + { + using FileStream source = new( + sourcePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + FileBufferSize, + FileOptions.SequentialScan); + using FileStream destination = new( + destinationPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + FileBufferSize, + FileOptions.SequentialScan | FileOptions.WriteThrough); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + + long length = 0; + int bytesRead; + while ((bytesRead = source.Read(buffer, 0, buffer.Length)) != 0) + { + destination.Write(buffer, 0, bytesRead); + hash.AppendData(buffer, 0, bytesRead); + length += bytesRead; + } + + destination.Flush(flushToDisk: true); + return new DeploymentFileFingerprint(length, Convert.ToHexString(hash.GetHashAndReset())); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + /// + /// 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, overwrite: 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(flushToDisk: 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 = new( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + FileBufferSize, + FileOptions.SequentialScan); + return new DeploymentFileFingerprint(stream.Length, Convert.ToHexString(SHA256.HashData(stream))); + } + + private static void EnsureFingerprintMatches( + string path, + DeploymentFileFingerprint expectedFingerprint, + string errorMessage) + { + if (ComputeFileFingerprint(path) != expectedFingerprint) + { + throw new InvalidDataException(errorMessage); + } + } + + private static void RequireExpectedTargetFingerprint( + string targetPath, + DeploymentFileFingerprint? expectedFingerprint, + string conflictMessage) + { + if (expectedFingerprint is null || ComputeFileFingerprint(targetPath) != expectedFingerprint) + { + throw new InvalidDataException(conflictMessage); + } + } + + private static void RequireExpectedRestoreTargetFingerprint( + string targetPath, + DeploymentFileFingerprint? deployedFingerprint, + DeploymentFileFingerprint backupFingerprint, + string conflictMessage) + { + DeploymentFileFingerprint targetFingerprint = ComputeFileFingerprint(targetPath); + if (targetFingerprint != backupFingerprint && + (deployedFingerprint is null || targetFingerprint != deployedFingerprint)) + { + throw new InvalidDataException(conflictMessage); + } + } + + 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 must stay inside the deployment directory.", + "Deployment backup staging paths must not contain reparse points."); + 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 ex) + { + _logger.LogWarning(ex, "Could not inspect deployment staging file {FileName}.", Path.GetFileName(stagingPath)); + } + } + + private 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"); + } + + private static bool ArePathsOnSameVolume(string firstPath, string secondPath) + { + string firstDirectory = Directory.Exists(firstPath) + ? firstPath + : Path.GetDirectoryName(firstPath) + ?? throw new InvalidOperationException("Deployment file paths must have a parent directory."); + string secondDirectory = Directory.Exists(secondPath) + ? secondPath + : Path.GetDirectoryName(secondPath) + ?? throw new InvalidOperationException("Deployment file paths must have a parent directory."); + return PhysicalDirectoryPath.GetIdentity(firstDirectory).VolumeSerialNumber == + PhysicalDirectoryPath.GetIdentity(secondDirectory).VolumeSerialNumber; + } + + /// + /// Verifies that a game-directory mutation target stays in the game folder and does not cross child reparse points. + /// + private static void EnsureSafeGameMutationPath(LauncherPaths paths, string targetPath) + { + _ = FileSystemPathSafety.ResolveOwnedSubpath( + paths.GameDirectory, + targetPath, + "Deployment target paths must stay inside the game directory.", + "Deployment target paths must not contain reparse points."); + } + + 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 GenLauncherGO.Core.Mods.Models.OwnedContentPath( + deploymentPaths.DeploymentDirectory, + backupRoot)); + } + +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs new file mode 100644 index 00000000..07b3faf8 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs @@ -0,0 +1,547 @@ +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.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.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 IContentIntegrityService _integrityService; + + private readonly ILaunchContentIntegrityTargetBuilder _targetBuilder; + + private readonly IS3ObjectManifestReader _manifestReader; + + private readonly IS3PackageUpdater _s3PackageUpdater; + + private readonly ISingleFilePackageUpdater _singleFilePackageUpdater; + + private readonly IRemoteAssetDownloader _assetDownloader; + + private readonly ILauncherContentCatalog _catalog; + + private readonly ILogger _logger; + + public FileSystemLaunchContentIntegrityResolutionService( + IContentIntegrityService integrityService, + ILaunchContentIntegrityTargetBuilder targetBuilder, + IS3ObjectManifestReader manifestReader, + IS3PackageUpdater s3PackageUpdater, + ISingleFilePackageUpdater singleFilePackageUpdater, + IRemoteAssetDownloader assetDownloader, + ILauncherContentCatalog catalog, + ILogger logger) + { + _integrityService = integrityService ?? throw new ArgumentNullException(nameof(integrityService)); + _targetBuilder = targetBuilder ?? throw new ArgumentNullException(nameof(targetBuilder)); + _manifestReader = manifestReader ?? throw new ArgumentNullException(nameof(manifestReader)); + _s3PackageUpdater = s3PackageUpdater ?? throw new ArgumentNullException(nameof(s3PackageUpdater)); + _singleFilePackageUpdater = singleFilePackageUpdater ?? + throw new ArgumentNullException(nameof(singleFilePackageUpdater)); + _assetDownloader = assetDownloader ?? throw new ArgumentNullException(nameof(assetDownloader)); + _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); + return new LaunchContentIntegrityVerificationResult(report, contexts); + } + + public async Task InitializeUntrackedManagedCachesAsync( + LaunchContentIntegrityResolutionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var untrackedManagedCacheIds = request.Report.Issues + .Where(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.SourceKind is ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile) + .Select(issue => issue.TargetId) + .Where(targetId => request.Report.Issues + .Where(issue => issue.TargetId == targetId) + .All(issue => issue.Kind == IntegrityIssueKind.Untracked)) + .ToHashSet(StringComparer.Ordinal); + var cacheContexts = request.TargetContexts + .Where(context => + context.IsCache && + untrackedManagedCacheIds.Contains(context.Target.Id)) + .ToList(); + + bool initializedAny = false; + foreach (LaunchContentIntegrityTargetContext context in cacheContexts) + { + if (!await _integrityService.CaptureSnapshotIfMatchesExpectedFileSetAsync( + request.Paths, + context.Target, + BuildExpectedRemoteCachePaths(context), + cancellationToken)) + { + continue; + } + + _logger.LogInformation( + "Initialized managed remote image integrity for {ContentName}.", + context.Version.DisplayName); + initializedAny = true; + } + + return initializedAny; + } + + public async Task ResolveAsync( + LaunchContentIntegrityResolutionRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var contextIndex = + request.TargetContexts.ToDictionary(context => context.Target.Id, StringComparer.Ordinal); + + foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context => + request.Report.Issues.Any(issue => + issue.TargetId == context.Target.Id && + issue.Action == IntegrityIssueAction.TrustAsManual))) + { + context.Version.Installation.ContentSourceKind = ContentSourceKind.Manual; + ContentIntegrityTarget manualTarget = context.Target with + { + SourceKind = ContentSourceKind.Manual, + }; + await _integrityService.CaptureSnapshotAsync(request.Paths, manualTarget, cancellationToken); + } + + _catalog.SaveLauncherData(); + + foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context => + request.Report.Issues.Any(issue => + issue.TargetId == context.Target.Id && + issue.Action == IntegrityIssueAction.Absorb))) + { + await _integrityService.CaptureSnapshotAsync(request.Paths, context.Target, cancellationToken); + } + + await _integrityService.ApplyCleanupAsync( + request.Report, + request.TargetContexts.Select(context => context.Target).ToList(), + cancellationToken); + + foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context => + request.Report.Issues.Any(issue => + issue.TargetId == context.Target.Id && + issue.Action is IntegrityIssueAction.Repair or IntegrityIssueAction.Redownload))) + { + if (context.IsCache) + { + await RefreshManagedCacheAsync(context, cancellationToken); + progress?.Report(LaunchContentIntegrityResolutionProgress.Complete(context.Target.Id)); + } + else + { + await RepairManagedPackageAsync( + request.Paths, + context, + request.Report, + new TargetPackageProgress(context.Target.Id, progress), + cancellationToken); + } + } + + foreach (string targetId in request.Report.Issues + .Where(issue => + issue.SourceKind is ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile) + .Select(issue => issue.TargetId) + .Distinct(StringComparer.Ordinal)) + { + if (contextIndex.TryGetValue(targetId, out LaunchContentIntegrityTargetContext? context)) + { + await _integrityService.CaptureSnapshotAsync(request.Paths, context.Target, cancellationToken); + } + } + } + + 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); + + if (request.Version.ModificationType == ModificationType.Mod) + { + await _integrityService.CaptureSnapshotAsync( + request.Paths, + contexts.First(context => context.IsCache).Target, + cancellationToken); + } + } + + public async Task CaptureManagedInstallSnapshotAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (request.Version.EffectiveContentSourceKind is not + (ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile)) + { + return; + } + + IReadOnlyList contexts = BuildSingleVersionContexts(request); + await _integrityService.CaptureSnapshotAsync( + request.Paths, + contexts.First(context => !context.IsCache).Target, + cancellationToken); + + if (request.Version.ModificationType == ModificationType.Mod) + { + LaunchContentIntegrityTargetContext cacheContext = contexts.First(context => context.IsCache); + if (!await _integrityService.CaptureSnapshotIfMatchesExpectedFileSetAsync( + request.Paths, + cacheContext.Target, + BuildExpectedRemoteCachePaths(cacheContext), + cancellationToken)) + { + await RefreshManagedCacheAsync(cacheContext, cancellationToken); + await _integrityService.CaptureSnapshotAsync(request.Paths, cacheContext.Target, cancellationToken); + } + } + } + + 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); + } + + 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( + GenLauncherGO.Core.Startup.LauncherPaths paths, + LaunchContentIntegrityTargetContext context, + ContentIntegrityReport report, + IProgress progress, + CancellationToken cancellationToken) + { + ContentSourceKind sourceKind = context.Version.EffectiveContentSourceKind; + var installedPath = new OwnedContentPath(paths.ModsDirectory, context.Target.RootDirectory); + var packagePaths = PackageUpdatePathSet.Create( + paths, + installedPath, + installedPath); + if (sourceKind == ContentSourceKind.ManagedS3) + { + S3ObjectManifestRequest manifestRequest = S3CatalogDefaults.CreateManifestRequest(context.Version); + IReadOnlyList files = await _manifestReader.ReadManifestAsync( + manifestRequest, + cancellationToken); + var hashCheckedExtensions = files + .Select(file => Path.GetExtension(file.FileName)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + hashCheckedExtensions.Add(BigFileVariantPath.GibExtension); + + IReadOnlyList repairFiles = SelectS3FileRepairEntries( + report, + context.Target.Id, + 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, + manifestRequest, + installedPath, + hashCheckedExtensions), + progress, + cancellationToken); + return; + } + + _logger.LogInformation( + "Repairing S3 package {ContentName} with full package replacement.", + context.Version.DisplayName); + + await _s3PackageUpdater.UpdateAsync( + new S3PackageUpdateRequest( + files, + manifestRequest, + packagePaths, + hashCheckedExtensions), + progress, + cancellationToken); + return; + } + + if (sourceKind == ContentSourceKind.ManagedSingleFile) + { + await _singleFilePackageUpdater.UpdateAsync( + DownloadLinkResolver.ResolveDownloadUri(context.Version.SimpleDownloadLink), + packagePaths, + progress, + cancellationToken); + 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 = new(); + foreach (RemoteFileManifestEntry file in files) + { + string manifestRelativePath = ManifestPathResolver.NormalizeForManifestIndex(file.FileName); + string installedRelativePath = GetInstalledS3RelativePath(file.FileName); + bool matchesIssue = remainingIssuePaths.Remove(manifestRelativePath); + matchesIssue |= remainingIssuePaths.Remove(installedRelativePath); + if (matchesIssue) + { + selectedFiles.Add(file); + } + } + + return remainingIssuePaths.Count == 0 + ? selectedFiles + : Array.Empty(); + } + + /// + /// Converts a manifest file name into the relative path expected in an installed package snapshot. + /// + private static string GetInstalledS3RelativePath(string manifestFileName) + { + string normalizedPath = ManifestPathResolver.NormalizeForManifestIndex(manifestFileName); + return LexicalPath.NormalizeRelativePath( + BigFileVariantPath.GetInstalledPath(normalizedPath)); + } + + /// + /// 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 must resolve to a rooted path.", + "Content metadata resolved through a linked launcher-owned directory."); + Directory.CreateDirectory(target.RootDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + target.RootDirectory, + "Content metadata must resolve to a rooted path.", + "Content metadata resolved through a linked launcher-owned directory."); + + IReadOnlyList assets = BuildRemoteCacheAssets(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 (RemoteCacheAsset asset in assets) + { + await _assetDownloader.DownloadIfMissingAsync( + asset.SourceUri, + asset.DestinationPath, + cancellationToken); + } + } + + private static HashSet BuildExpectedRemoteCachePaths(LaunchContentIntegrityTargetContext context) + { + return BuildRemoteCacheAssets(context.Version, context.Target.RootDirectory) + .Select(asset => ContentIntegrityPath.GetRelativePath( + context.Target.RootDirectory, + asset.DestinationPath)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + private static IReadOnlyList BuildRemoteCacheAssets( + LauncherContentVersion version, + string cacheDirectory) + { + if (!Uri.TryCreate(version.UIImageSourceLink, UriKind.Absolute, out Uri? sourceUri)) + { + return Array.Empty(); + } + + return new[] + { + new RemoteCacheAsset( + sourceUri, + ModificationImageCachePath.ResolveRemoteImagePath( + cacheDirectory, + version.Version, + sourceUri)), + }; + } + + /// + /// 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 string _targetId; + + private readonly IProgress? _progress; + + public TargetPackageProgress( + string targetId, + IProgress? progress) + { + ArgumentException.ThrowIfNullOrWhiteSpace(targetId); + + _targetId = targetId; + _progress = progress; + } + + public void Report(PackageUpdateProgress value) + { + _progress?.Report(LaunchContentIntegrityResolutionProgress.Package(_targetId, value)); + } + } + + private sealed record RemoteCacheAsset( + Uri SourceUri, + string DestinationPath); +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs new file mode 100644 index 00000000..563412c3 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs @@ -0,0 +1,194 @@ +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, + isCache: false)); + + if (version.ModificationType == ModificationType.Mod) + { + contexts.Add(new LaunchContentIntegrityTargetContext( + CreateCacheTarget(request, version), + version, + isCache: true)); + } + } + + if (contexts.Count > 0) + { + _logger.LogInformation( + "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 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 resolved outside a launcher-owned directory.", + "Content metadata resolved through a linked 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.Name); + HashSet ignoredPaths = BuildInactiveCacheIgnoredPaths(request, version, cacheDirectory); + + return new ContentIntegrityTarget( + CreateTargetId("cache", version.ContentKey), + version.DisplayName + " " + request.CacheDisplayNameSuffix, + cacheDirectory, + version.EffectiveContentSourceKind, + ignoredPaths); + } + + /// + /// Builds ignored cache paths that belong to inactive versions of the same modification. + /// + private HashSet BuildInactiveCacheIgnoredPaths( + 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 inactiveBaseNames = request.AllVersions + .Where(candidate => + !IsExactVersion(candidate, version) && + candidate.ContentKey.HasName(version.Name)) + .SelectMany(candidate => new[] + { + candidate.Version, + candidate.Version + "-background", + }) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (string filePath in EnumerateFilesWithoutLinks(cacheDirectory)) + { + string relativePath = LexicalPath.GetRelativePath(cacheDirectory, filePath); + if (inactiveBaseNames.Contains(Path.GetFileNameWithoutExtension(filePath))) + { + ignoredPaths.Add(relativePath); + } + } + + if (ignoredPaths.Count > 0) + { + _logger.LogDebug( + "Ignored {IgnoredPathCount} 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()); + } + + private static bool IsExactVersion( + LauncherContentVersion candidate, + LauncherContentVersion version) + { + return candidate.ContentKey == version.ContentKey; + } + + /// + /// Enumerates files without following linked directories. + /// + private static IEnumerable EnumerateFilesWithoutLinks(string rootDirectory) + { + return Directory.EnumerateFiles( + rootDirectory, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions()); + } + +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs new file mode 100644 index 00000000..3a55b6b5 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs @@ -0,0 +1,103 @@ +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 LauncherRuntimePathContext _runtimePathContext; + + private readonly ILogger _logger; + + 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; + var executables = new List(); + string communityExecutable = LauncherFileSystemLayout.GetCommunityGameExecutableName(paths.Game); + executables.Add(new GameClientExecutable( + communityExecutable, + GameClientExecutableKind.Community, + IsExecutableAvailable(communityExecutable, paths))); + + if (paths.Game == SupportedGame.ZeroHour) + { + executables.Add(new GameClientExecutable( + LauncherFileSystemLayout.GeneralsOnlineExecutableFileName, + GameClientExecutableKind.GeneralsOnline, + IsExecutableAvailable(LauncherFileSystemLayout.GeneralsOnlineExecutableFileName, paths))); + } + + return executables; + } + + public IReadOnlyList GetWorldBuilders() + { + LauncherPaths paths = _runtimePathContext.ActivePaths; + var executables = new List(); + + executables.Add(new WorldBuilderExecutable( + LauncherFileSystemLayout.VanillaWorldBuilderExecutableFileName, + WorldBuilderExecutableKind.Vanilla, + IsExecutableAvailable(LauncherFileSystemLayout.VanillaWorldBuilderExecutableFileName, paths))); + + string communityExecutable = LauncherFileSystemLayout.GetCommunityWorldBuilderExecutableName(paths.Game); + executables.Add(new WorldBuilderExecutable( + communityExecutable, + WorldBuilderExecutableKind.Community, + IsExecutableAvailable(communityExecutable, paths))); + + return executables; + } + + 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; + } + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs new file mode 100644 index 00000000..b7f51fae --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs @@ -0,0 +1,154 @@ +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 IProcessFamilyLauncher _processFamilyLauncher; + + private readonly ILogger _logger; + + 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 executableName = Path.Combine(request.GameDirectory, request.ExecutableName); + try + { + EnsureExecutableCanLaunch(executableName); + IProcessFamilyLaunchOperation operation = await _processFamilyLauncher.StartAsync( + executableName, + request.Arguments, + request.GameDirectory, + cancellationToken).ConfigureAwait(false); + return new WindowsGameProcessLaunchOperation( + request.TargetKind, + executableName, + operation, + _logger); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Failed to launch {ExecutableName}.", executableName); + throw; + } + } + + 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 GameLaunchTargetKind _targetKind; + + private readonly string _executableName; + + private readonly IProcessFamilyLaunchOperation _processFamilyOperation; + + private readonly ILogger _logger; + + 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.LogInformation( + "Launch of {ExecutableName} ended after {RunningDurationMs}ms, below the success threshold.", + _executableName, + runningDuration.TotalMilliseconds); + return false; + } + + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Failed while waiting for launched process {ExecutableName}.", _executableName); + throw; + } + 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..b179e791 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsProcessFamilyLauncher.cs @@ -0,0 +1,664 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Starts Windows processes and waits until the launched process family has exited. +/// +internal sealed class WindowsProcessFamilyLauncher : IProcessFamilyLauncher +{ + private const int ProcessPollMilliseconds = 500; + + // Some launchers exit before their replacement child becomes visible in a process snapshot. + private const int ProcessHandoffGraceMilliseconds = 5000; + + 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); + return new WindowsProcessFamilyLaunchOperation( + executableName, + process, + new ProcessFamilyTracker(process.Id, executableName, _logger), + cancellationToken, + _logger); + } + + private static Process StartExecutable( + string executableName, + string arguments, + string workingDirectory) + { + var process = Process.Start(new ProcessStartInfo + { + FileName = executableName, + Arguments = arguments, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + }); + if (process == null) + { + throw new InvalidOperationException($"Failed to start {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(typeof(NativeProcessEntry)), + }; + 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); + + /// + /// Observes a started Windows process family and exposes a force-close command for it. + /// + private sealed class WindowsProcessFamilyLaunchOperation : IProcessFamilyLaunchOperation + { + private readonly ILogger _logger; + + private readonly string _executableName; + + private readonly Process _rootProcess; + + private readonly ProcessFamilyTracker _processFamily; + + private readonly CancellationToken _cancellationToken; + + private readonly object _syncRoot = new(); + + private bool _disposed; + + public WindowsProcessFamilyLaunchOperation( + string executableName, + Process rootProcess, + ProcessFamilyTracker processFamily, + CancellationToken cancellationToken, + ILogger logger) + { + 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.LogWarning( + "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 Func _isProcessRunning; + + private readonly Func _getUtcNow; + + private readonly Action _forceCloseProcess; + + private readonly object _syncRoot = new(); + + private readonly TimeSpan _handoffGracePeriod; + + // Retain recently exited parents so a replacement child appearing in a later snapshot is still discovered. + private readonly Dictionary _trackedProcesses = new(); + + private readonly int _rootProcessId; + + private readonly DateTime _startedAtUtc; + + private readonly ILogger _logger; + + // 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 bool _childProcessObserved; + + private bool _snapshotFailureLogged; + + private string _currentExecutableName; + + private int _nextProcessOrder; + + public ProcessFamilyTracker( + int rootProcessId, + string rootExecutableName, + ILogger logger) + : this( + rootProcessId, + rootExecutableName, + logger, + TryCaptureProcessSnapshot, + IsProcessRunning, + () => DateTime.UtcNow, + TimeSpan.FromMilliseconds(ProcessHandoffGraceMilliseconds), + ForceCloseProcess) + { + } + + internal ProcessFamilyTracker( + int rootProcessId, + string rootExecutableName, + ILogger logger, + Func?> captureProcessSnapshot, + Func isProcessRunning, + Func getUtcNow, + 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)); + _getUtcNow = getUtcNow ?? throw new ArgumentNullException(nameof(getUtcNow)); + _handoffGracePeriod = handoffGracePeriod; + _forceCloseProcess = forceCloseProcess ?? throw new ArgumentNullException(nameof(forceCloseProcess)); + _startedAtUtc = _getUtcNow(); + _lastObservedRunningAtUtc = _startedAtUtc; + _currentExecutableName = NormalizeExecutableName(rootExecutableName); + _trackedProcesses.Add( + rootProcessId, + new TrackedProcess(_currentExecutableName, depth: 0, order: _nextProcessOrder++)); + } + + 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 = _getUtcNow(); + ExpireRetiredProcessIds(nowUtc); + TrackDescendants(entries); + + 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; + } + + _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); + 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.LogWarning("Force closed launched process {ProcessId}.", processId); + } + catch (ArgumentException) + { + _logger.LogInformation( + "Tracked launched process {ProcessId} exited before force close completed.", + processId); + } + catch (InvalidOperationException) + { + _logger.LogInformation( + "Tracked launched process {ProcessId} exited before force close completed.", + processId); + } + catch (Win32Exception ex) + { + _logger.LogWarning(ex, "Failed to force close launched process {ProcessId}.", processId); + } + catch (NotSupportedException ex) + { + _logger.LogWarning(ex, "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) + { + 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++); + _trackedProcesses.Add(entry.ProcessId, trackedProcess); + + _logger.LogInformation( + "Tracking launched child process {ProcessId} ({ExecutableName}) for cleanup wait.", + entry.ProcessId, + trackedProcess.ExecutableName); + } + } while (addedProcess); + } + + 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 = _getUtcNow(); + 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 executableName?.Trim() ?? string.Empty; + } + + private sealed class TrackedProcess + { + public TrackedProcess(string executableName, int depth, int order) + { + ExecutableName = executableName; + Depth = depth; + Order = order; + } + + public string ExecutableName { get; } + + public int Depth { get; } + + public int Order { get; } + + public DateTime? RetiredAtUtc { get; set; } + } + } + + internal sealed record ProcessSnapshotEntry( + int ProcessId, + int ParentProcessId, + string ExecutableFileName = ""); + + 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(entireProcessTree: true); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs new file mode 100644 index 00000000..f434eb20 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs @@ -0,0 +1,106 @@ +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 paths must be rooted.", + "Deployment package directories must not contain reparse points."); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + packageRoot, + "Deployment package directories must not contain reparse points."); + + foreach (string sourcePath in Directory.EnumerateFiles( + packageRoot, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions())) + { + string extension = Path.GetExtension(sourcePath); + // 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) && + !string.Equals(LexicalPath.NormalizeFullPath(current), root, StringComparison.OrdinalIgnoreCase) && + !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..978cddd9 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs @@ -0,0 +1,59 @@ +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) + { + string normalizedPath = NormalizeManifestPath(relativePath); + string deploymentRoot = LexicalPath.NormalizeFullPath(deploymentDirectory); + string candidatePath = LexicalPath.ResolvePath(deploymentRoot, normalizedPath); + + if (!LexicalPath.IsPathInDirectory(candidatePath, deploymentRoot)) + { + throw new InvalidDataException( + $"Deployment state path '{relativePath}' is outside the deployment directory."); + } + + return candidatePath; + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentResult.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentResult.cs new file mode 100644 index 00000000..eae49d04 --- /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 infrastructure diagnostics to the launch-preparation boundary for logging. +/// +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..c928f235 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs @@ -0,0 +1,700 @@ +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, deploymentId: string.Empty); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, deploymentPaths.DeploymentDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + deploymentPaths.LockPath, + "Deployment lock paths must be rooted.", + "Deployment lock paths must not contain reparse points."); + + 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 must be rooted.", + "Deployment manifest paths must not contain reparse points."); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + paths.JournalPath, + "Deployment journal paths must be rooted.", + "Deployment journal paths must not contain reparse points."); + + if (File.Exists(paths.ActiveManifestPath)) + { + File.Delete(paths.ActiveManifestPath); + } + + if (File.Exists(paths.JournalPath)) + { + File.Delete(paths.JournalPath); + } + } + + /// + /// Durably appends one record before or after each recoverable file-system mutation. + /// + public static void AppendJournal(string journalPath, DeploymentJournalRecord record) + { + string journalDirectory = Path.GetDirectoryName(journalPath) + ?? throw new InvalidOperationException( + "Deployment journal paths must have a parent directory."); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + journalDirectory, + "Deployment journal paths must be rooted.", + "Deployment journal paths must not contain reparse points."); + Directory.CreateDirectory(journalDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + journalPath, + "Deployment journal paths must be rooted.", + "Deployment journal paths must not contain reparse points."); + + byte[] bytes = Encoding.UTF8.GetBytes( + JsonSerializer.Serialize(record, _journalJsonOptions) + Environment.NewLine); + using FileStream stream = new( + journalPath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + bufferSize: 4096, + FileOptions.WriteThrough); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(flushToDisk: true); + } + + /// + /// 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 must be rooted.", + "Deployment journal paths must not contain reparse points."); + 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 must be rooted.", + "Deployment manifest paths must not contain reparse points."); + if (!File.Exists(manifestPath)) + { + return null; + } + + try + { + DeploymentManifestDocument? manifest = + JsonSerializer.Deserialize(File.ReadAllText(manifestPath), _jsonOptions); + if (manifest is null) + { + readException = new InvalidDataException("The deployment manifest did not contain manifest data."); + _logger.LogWarning( + "Deployment manifest did not contain manifest data; journal recovery will be attempted."); + } + + return manifest; + } + catch (Exception ex) when (ex is IOException or JsonException or NotSupportedException) + { + readException = ex; + _logger.LogWarning(ex, "Deployment manifest could not be read; journal recovery will be attempted."); + return null; + } + } + + /// + /// Rebuilds 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 ex) + { + _logger.LogWarning(ex, "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; + } + + if (string.Equals(record.Action, DeploymentJournalRecord.DirectoryCreatedAction, StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + directories.Add(record.TargetRelativePath); + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileBackupStartedAction, + StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + if (!string.IsNullOrWhiteSpace(record.BackupRelativePath)) + { + var backup = new DeploymentBackupDocument( + record.BackupRelativePath, + record.BackupFingerprint, + record.StagingRelativePath); + backupStartsByTarget[record.TargetRelativePath] = backup; + backupsByTarget[record.TargetRelativePath] = backup; + } + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileBackedUpAction, + StringComparison.Ordinal)) + { + 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, + DeployedFingerprint: null, + BackupFingerprint: record.BackupFingerprint, + StagingRelativePath: null, + BackupStagingRelativePath: record.StagingRelativePath); + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileDeploymentStartedAction, + StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + string targetRelativePath = record.TargetRelativePath ?? string.Empty; + 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); + } + else if (string.Equals(record.Action, DeploymentJournalRecord.FileDeployedAction, StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + string targetRelativePath = record.TargetRelativePath ?? string.Empty; + 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); + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileCleanupDeleteCompletedAction, + StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + filesByTarget.Remove(record.TargetRelativePath); + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileCleanupRestoreStartedAction, + StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + if (filesByTarget.TryGetValue( + record.TargetRelativePath, + out DeploymentFileDocument? restoringFile)) + { + filesByTarget[record.TargetRelativePath] = restoringFile with + { + RestoreStagingRelativePath = record.StagingRelativePath + }; + } + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileCleanupRestoredAction, + StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + filesByTarget.Remove(record.TargetRelativePath); + } + } + + 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, + DeployedFingerprint: null, + BackupFingerprint: backupStart.Value.Fingerprint, + StagingRelativePath: null, + BackupStagingRelativePath: 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 must stay inside the deployment directory.", + "Deployment backup staging paths must not contain reparse points."); + 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) || + !string.Equals( + LexicalPath.NormalizeFullPath(manifest.GameRoot), + PhysicalDirectoryPath.ResolveExisting(paths.GameDirectory), + StringComparison.OrdinalIgnoreCase)) + { + 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) + { + PhysicalDirectoryIdentity identity = PhysicalDirectoryPath.GetIdentity(gameDirectory); + 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); + +/// +/// 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) +{ + 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) + { + return new DeploymentJournalRecord( + FileDeployedAction, + targetRelativePath, + backupRelativePath, + method, + DeployedFingerprint: deployedFingerprint, + BackupFingerprint: backupFingerprint, + StagingRelativePath: stagingRelativePath); + } + + 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..d2168384 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/IHardLinkCreator.cs @@ -0,0 +1,12 @@ +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Creates hard links between installed package files and game-directory targets. +/// +internal interface IHardLinkCreator +{ + /// + /// 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..e85b1534 --- /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; } + + /// + /// Occurs when changes. + /// + event EventHandler? CurrentExecutableNameChanged; + + /// + /// Gets the task that completes when every tracked process in the launched process family has exited. + /// + Task Completion { get; } + + /// + /// 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..d9caac95 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLauncher.cs @@ -0,0 +1,20 @@ +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..675675fb --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/WindowsHardLinkCreator.cs @@ -0,0 +1,43 @@ +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// 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 TryCreateHardLink(string targetPath, string sourcePath) + { + bool created = CreateHardLink(targetPath, sourcePath, lpSecurityAttributes: 0); + if (!created) + { + int errorCode = Marshal.GetLastWin32Error(); + _logger.LogWarning( + "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; + } + + [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..7e883609 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Logging/LoggingServiceCollectionExtensions.cs @@ -0,0 +1,92 @@ +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; + +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) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(logDirectory); + + Directory.CreateDirectory(logDirectory); + + string logFilePath = CreateLogFilePath(logDirectory); + PruneOldLogFiles(logDirectory, logFilePath); + Serilog.ILogger logger = new LoggerConfiguration() + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.File( + new SensitiveDataRedactingTextFormatter(), + logFilePath, + shared: false) + .CreateLogger(); + + services.AddLogging(builder => + { + builder.ClearProviders(); + builder.AddSerilog(logger, dispose: 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)); + + string activePath = LexicalPath.NormalizeFullPath(activeLogFilePath); + int retainedCount = 1; + foreach (FileInfo logFile in logFiles) + { + if (string.Equals( + LexicalPath.NormalizeFullPath(logFile.FullName), + activePath, + StringComparison.OrdinalIgnoreCase)) + { + 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..c7bfb66f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Logging/SensitiveDataRedactingTextFormatter.cs @@ -0,0 +1,90 @@ +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 class SensitiveDataRedactingTextFormatter : ITextFormatter +{ + /// + /// Replaces source-file paths emitted by exception stack traces. + /// + private static readonly Regex _stackTraceSourcePathPattern = new( + @"\sin\s[A-Za-z]:\\[^\r\n]*:line\s(?\d+)", + RegexOptions.Compiled); + + /// + /// Replaces UNC paths before drive-letter paths so adjacent path values cannot consume the UNC introducer. + /// + private static readonly Regex _uncWindowsPathPattern = new( + @"\\\\[^\\/\r\n:*?""<>|]+[\\/][^\\/\r\n:*?""<>|]+(?:[\\/][^\\/\r\n:*?""<>|]*)*", + RegexOptions.Compiled); + + /// + /// Replaces absolute drive-letter paths using either Windows path separator. + /// + private static readonly Regex _absoluteDriveWindowsPathPattern = new( + @"(?|]+[\\/])*[^\\/\r\n:*?""<>|]*", + RegexOptions.Compiled); + + /// + /// Replaces URI user-info credentials. + /// + private static readonly Regex _uriUserInfoPattern = new( + @"(?i)(?\b[a-z][a-z0-9+.-]*://)[^/\s?#@]+@", + RegexOptions.Compiled); + + /// + /// Replaces common token, key, credential, secret, signature, and password query-string values. + /// + private static readonly Regex _sensitiveQueryValuePattern = new( + @"(?i)(?[?&](?:access[_-]?token|api[_-]?key|credential|secret|token|session[_-]?token|" + + @"security[_-]?token|password|signature|sig|x-amz-(?:credential|signature|security-token))=)[^&\s]+", + RegexOptions.Compiled); + + 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]"); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherContentStateStore.cs b/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherContentStateStore.cs new file mode 100644 index 00000000..840d7a85 --- /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..39d27129 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Contracts/ILocalLauncherContentService.cs @@ -0,0 +1,38 @@ +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); + + /// + /// 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 cached images for a content version 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..5c39010f --- /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; } = + new List(); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentState.cs b/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentState.cs new file mode 100644 index 00000000..62b8311f --- /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; } = new List(); + + public List Modifications { get; set; } = new List(); + + public List Patches { get; set; } = new List(); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentVersionState.cs b/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentVersionState.cs new file mode 100644 index 00000000..49f4c094 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentVersionState.cs @@ -0,0 +1,24 @@ +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; } + + 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..74020bbd --- /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; } = new(); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogModificationReference.cs b/GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogModificationReference.cs new file mode 100644 index 00000000..691e30b9 --- /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; } = new(); + + public List ModAddons { get; set; } = new(); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LegacyContentManifest.cs b/GenLauncherGO.Infrastructure/Mods/Models/LegacyContentManifest.cs new file mode 100644 index 00000000..16185059 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LegacyContentManifest.cs @@ -0,0 +1,46 @@ +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; + + public string UIImageSourceLink { get; set; } = string.Empty; + + public string DiscordLink { get; set; } = string.Empty; + + 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; + + public ContentSourceKind ContentSourceKind { get; set; } = ContentSourceKind.UnknownLegacy; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LegacyLauncherCatalogDocument.cs b/GenLauncherGO.Infrastructure/Mods/Models/LegacyLauncherCatalogDocument.cs new file mode 100644 index 00000000..65128a94 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LegacyLauncherCatalogDocument.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +#pragma warning disable IDE1006 // Member names preserve the third-party YAML schema exactly. + +/// +/// 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; } = new(); + + public List globalAddonsData { get; set; } = new(); + + public List modDatas { get; set; } = new(); + + public List originalGameAddons { get; set; } = new(); + + public List originalGamePatches { get; set; } = new(); + + public string LauncherVersion { get; set; } = string.Empty; +} + +#pragma warning restore IDE1006 diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteAdvertisingReference.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteAdvertisingReference.cs new file mode 100644 index 00000000..334ff2b1 --- /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..54d0615a --- /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..9f800ac3 --- /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..9bfe7360 --- /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..67d81b4d --- /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..08ba83c7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemLocalLauncherContentService.cs @@ -0,0 +1,374 @@ +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 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 must be rooted.", + "Launcher content paths must not contain reparse points."); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + paths.ModsDirectory, + "Launcher content paths must not contain reparse points."); + + foreach (DirectoryInfo contentDirectory in modsDirectory.GetDirectories()) + { + AddInstalledVersions(contentDirectory, versions); + } + + return versions; + } + + public void DeleteVersion( + LauncherPaths paths, + LauncherContentKey contentKey) + { + ArgumentNullException.ThrowIfNull(paths); + + OwnedContentPath? versionPath; + try + { + versionPath = LauncherContentPathResolver.ResolveVersionPath(paths, contentKey); + } + catch (ArgumentException exception) + { + throw new InvalidOperationException( + "Refusing to delete a launcher content path outside the mods root.", + exception); + } + + if (versionPath is null) + { + return; + } + + bool deletedInstalledVersion = DeleteDirectoryIfExists( + versionPath, + "Deleted launcher content version {ContentName} {ContentVersion}.", + contentKey.Name, + contentKey.Version); + + DeletePackageStagingDirectory(paths, versionPath, contentKey); + + if (deletedInstalledVersion) + { + OwnedContentPath? cleanupRoot = LauncherContentPathResolver.ResolveCleanupRootPath(paths, contentKey); + if (cleanupRoot is not null) + { + OwnedDirectoryTree.DeleteEmptyDirectories(cleanupRoot); + } + } + } + + public void DeleteContent( + LauncherPaths paths, + LauncherContentKey contentKey) + { + ArgumentNullException.ThrowIfNull(paths); + + OwnedContentPath? contentPath; + try + { + contentPath = LauncherContentPathResolver.ResolveContentPath(paths, contentKey); + } + catch (ArgumentException exception) + { + throw new InvalidOperationException( + "Refusing to delete a launcher content path outside the mods root.", + exception); + } + + if (contentPath is null) + { + return; + } + + bool deletedContent = DeleteDirectoryIfExists( + contentPath, + "Deleted launcher content {ContentName} {ContentVersion}.", + contentKey.Name, + contentKey.Version); + + DeletePackageStagingDirectory(paths, contentPath, contentKey); + + if (deletedContent) + { + 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) || + string.IsNullOrWhiteSpace(contentKey.Version) || + ContentCardExists(launcherData, contentKey.Name)) + { + return; + } + + string imageFolderPath = paths.GetModificationImagesDirectory(contentKey.Name); + if (!Directory.Exists(imageFolderPath)) + { + return; + } + + var ownedImagePath = new OwnedContentPath(paths.ImagesDirectory, imageFolderPath); + if (FileSystemPathSafety.IsReparsePoint(imageFolderPath)) + { + OwnedDirectoryTree.DeleteIfExists(ownedImagePath); + _logger.LogWarning( + "Removed linked modification image cache folder {ImageFolderName} without traversing its target.", + Path.GetFileName(imageFolderPath)); + return; + } + + EnsureOwnedContentTreeIsSafe(ownedImagePath); + DeleteImageFiles(imageFolderPath, contentKey.Version); + DeleteImageFiles(imageFolderPath, contentKey.Version + "-background"); + DeleteImageFolderIfEmpty(imageFolderPath); + } + + private static void AddInstalledVersions( + DirectoryInfo contentDirectory, + List versions) + { + foreach (DirectoryInfo subDirectory in contentDirectory.GetDirectories()) + { + if (String.Equals( + subDirectory.Name, + LauncherFileSystemLayout.AddonsFolderName, + StringComparison.OrdinalIgnoreCase)) + { + foreach (DirectoryInfo addonDirectory in subDirectory.GetDirectories()) + { + AddInstalledChildVersions( + addonDirectory, + contentDirectory.Name, + ModificationType.Addon, + versions); + } + + continue; + } + + if (String.Equals( + subDirectory.Name, + LauncherFileSystemLayout.PatchesFolderName, + StringComparison.OrdinalIgnoreCase)) + { + foreach (DirectoryInfo patchDirectory in subDirectory.GetDirectories()) + { + AddInstalledChildVersions( + patchDirectory, + contentDirectory.Name, + ModificationType.Patch, + 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 {ContentName} {ContentVersion}.", + contentKey.Name, + contentKey.Version); + DeleteEmptyPackageStagingParents(packageStagingPath); + } + + /// + /// 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.LogInformation( + "Deleted empty temporary launcher package staging folder {StagingFolderName}.", + Path.GetFileName(deletedDirectory)); + } + } + + private bool DeleteDirectoryIfExists( + OwnedContentPath ownedPath, + string logMessage, + string contentName, + string contentVersion) + { + if (!OwnedDirectoryTree.DeleteIfExists(ownedPath)) + { + return false; + } + + _logger.LogInformation(logMessage, contentName, contentVersion); + return true; + } + + /// + /// Rejects reparse points in an owned content path and its existing directory tree before traversal. + /// + private static void EnsureOwnedContentTreeIsSafe(OwnedContentPath ownedPath) + { + FileSystemPathSafety.ResolveOwnedSubpath( + ownedPath.OwnerRoot, + ownedPath.FullPath, + "Launcher content paths must remain below their owning root.", + "Launcher content paths must not contain reparse points."); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + ownedPath.FullPath, + "Launcher content paths must not contain reparse points."); + } + + private static bool ContentCardExists(LauncherData launcherData, string contentName) + { + return ContainsContentName(launcherData.Modifications, contentName) || + ContainsContentName(launcherData.Addons, contentName) || + ContainsContentName(launcherData.Patches, contentName); + } + + private static bool ContainsContentName(IEnumerable entries, string contentName) + { + return entries.Any(entry => entry.ContentKey.HasName(contentName)); + } + + private void DeleteImageFiles(string imageFolderPath, string imageBaseName) + { + foreach (string imageFilePath in Directory.EnumerateFiles(imageFolderPath, imageBaseName + ".*")) + { + try + { + File.Delete(imageFilePath); + _logger.LogInformation( + "Deleted cached modification image {ImageFileName}.", + Path.GetFileName(imageFilePath)); + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to delete cached modification image {ImageFileName}.", + Path.GetFileName(imageFilePath)); + } + } + } + + private void DeleteImageFolderIfEmpty(string imageFolderPath) + { + try + { + if (!Directory.EnumerateFileSystemEntries(imageFolderPath).Any()) + { + Directory.Delete(imageFolderPath); + _logger.LogInformation( + "Deleted empty modification image cache folder {ImageFolderName}.", + Path.GetFileName(imageFolderPath)); + } + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to delete empty modification image cache folder {ImageFolderName}.", + Path.GetFileName(imageFolderPath)); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/FileSystemManualModificationImporter.cs b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemManualModificationImporter.cs new file mode 100644 index 00000000..4653a610 --- /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.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Archives; +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 (ArchiveFileSupport.IsSupported(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 (!string.Equals(installedFilePath, destinationFilePath, StringComparison.OrdinalIgnoreCase)) + { + 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 must stay inside their launcher-owned root.", + "Manual import destinations must not cross reparse points."); + destinationDirectory = OwnedDirectoryTree.EnsureExists( + destinationPath.OwnerRoot, + destinationDirectory); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + destinationDirectory, + "Manual import destinations must not contain reparse points."); + 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 must stay inside their destination directory.", + "Manual import files must not cross reparse points."); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationImageFileService.cs b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationImageFileService.cs new file mode 100644 index 00000000..de240608 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationImageFileService.cs @@ -0,0 +1,220 @@ +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 LauncherRuntimePathContext _runtimePathContext; + + private readonly ILogger _logger; + + public FileSystemModificationImageFileService( + LauncherRuntimePathContext runtimePathContext, + ILogger logger) + { + _runtimePathContext = runtimePathContext ?? throw new ArgumentNullException(nameof(runtimePathContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public string? FindExistingImageFilePath(string modificationName, string imageBaseName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modificationName); + ArgumentException.ThrowIfNullOrWhiteSpace(imageBaseName); + + LauncherPaths paths = _runtimePathContext.ActivePaths; + string imageDirectory = ModificationImageCachePath.ResolveDirectory(paths, modificationName); + if (!Directory.Exists(imageDirectory)) + { + return null; + } + + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + imageDirectory, + "Cached modification image directories must not contain reparse points."); + string? imageFilePath = Directory.EnumerateFiles( + imageDirectory, + GetImageSearchPattern(paths, modificationName, imageBaseName)) + .FirstOrDefault(); + return imageFilePath is null + ? null + : ModificationImageCachePath.ResolvePath(paths, imageFilePath); + } + + public int CountImageFiles(string modificationName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modificationName); + + LauncherPaths paths = _runtimePathContext.ActivePaths; + string imageDirectory = ModificationImageCachePath.ResolveDirectory(paths, modificationName); + if (!Directory.Exists(imageDirectory)) + { + return 0; + } + + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + imageDirectory, + "Cached modification image directories must not contain reparse points."); + 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 (exception is InvalidDataException or IOException + or UnauthorizedAccessException or ArgumentException + or NotSupportedException) + { + return false; + } + } + + public bool TryDeleteImage(string modificationName, string imageBaseName) + { + try + { + LauncherPaths paths = _runtimePathContext.ActivePaths; + string imageDirectory = ModificationImageCachePath.ResolveDirectory(paths, modificationName); + if (!Directory.Exists(imageDirectory)) + { + return true; + } + + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + imageDirectory, + "Cached modification image directories must not contain reparse points."); + string imageSearchPattern = GetImageSearchPattern(paths, modificationName, imageBaseName); + foreach (string imageFilePath in Directory.EnumerateFiles(imageDirectory, imageSearchPattern).ToList()) + { + File.Delete(ModificationImageCachePath.ResolvePath(paths, imageFilePath)); + } + + return true; + } + catch (Exception exception) when (exception is InvalidDataException or IOException + or UnauthorizedAccessException or ArgumentException + or NotSupportedException) + { + _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, request.ModificationName)); + string destinationPath = ModificationImageCachePath.ResolvePath( + paths, + paths.GetModificationImageFilePath( + request.ModificationName, + request.ImageBaseName + extension)); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + destinationDirectory, + "Cached modification image directories must not contain reparse points."); + + if (string.Equals(sourcePath, destinationPath, StringComparison.OrdinalIgnoreCase)) + { + 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 (exception is InvalidDataException or IOException + or UnauthorizedAccessException or ArgumentException + or NotSupportedException) + { + _logger.LogError( + exception, + "Could not replace cached modification image {ImageBaseName} for {ModificationName}.", + request.ImageBaseName, + request.ModificationName); + throw new IOException( + string.Format( + CultureInfo.InvariantCulture, + "Could not replace cached image '{0}' for modification '{1}'.", + request.ImageBaseName, + request.ModificationName), + exception); + } + } + + private static string GetImageSearchPattern( + LauncherPaths paths, + string modificationName, + string imageBaseName) + { + string validatedImagePath = paths.GetModificationImageFilePath( + modificationName, + imageBaseName + ".cache"); + return Path.GetFileNameWithoutExtension(validatedImagePath) + ".*"; + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherCatalogImageCache.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherCatalogImageCache.cs new file mode 100644 index 00000000..49d10cc4 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherCatalogImageCache.cs @@ -0,0 +1,174 @@ +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.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 remote launcher catalog images on disk. +/// +internal sealed class LauncherCatalogImageCache +{ + private readonly IRemoteAssetDownloader _assetDownloader; + + private readonly ILogger _logger; + + public LauncherCatalogImageCache( + IRemoteAssetDownloader assetDownloader, + ILogger logger) + { + _assetDownloader = assetDownloader ?? throw new ArgumentNullException(nameof(assetDownloader)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task CacheModificationImagesAsync( + LauncherContentVersion modification, + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(modification); + ArgumentNullException.ThrowIfNull(paths); + + if (String.IsNullOrEmpty(modification.UIImageSourceLink)) + { + return; + } + + await DownloadImageIfMissingAsync( + paths, + modification.Name, + modification.Version, + modification.UIImageSourceLink, + cancellationToken).ConfigureAwait(false); + } + + public async Task CacheAdvertisingImagesAsync( + RemoteAdvertisingReference advertising, + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(advertising); + ArgumentNullException.ThrowIfNull(paths); + + RemoveStaleAdvertisingImages(advertising, paths); + + var imageDownloads = new List(advertising.ImageUrls.Count); + int imageIndex = 0; + foreach (string imageLink in advertising.ImageUrls) + { + int currentImageIndex = imageIndex; + imageDownloads.Add(DownloadImageIfMissingAsync( + paths, + advertising.Name, + currentImageIndex.ToString(), + imageLink, + cancellationToken)); + imageIndex++; + } + + await Task.WhenAll(imageDownloads).ConfigureAwait(false); + } + + /// + /// Removes stale advertising image files when the remote image count changes. + /// + private void RemoveStaleAdvertisingImages(RemoteAdvertisingReference advertising, LauncherPaths paths) + { + string folderName = advertising.Name.Trim(Path.GetInvalidFileNameChars()); + try + { + string imageFolderPath = ModificationImageCachePath.ResolveDirectory(paths, folderName); + if (!Directory.Exists(imageFolderPath)) + { + return; + } + + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + imageFolderPath, + "Cached catalog image directories must not contain reparse points."); + 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 (exception is InvalidDataException or IOException + or UnauthorizedAccessException or ArgumentException + or NotSupportedException) + { + _logger.LogWarning( + exception, + "Skipped stale advertising image cleanup for {ModificationName} because its cache path was unavailable.", + advertising.Name); + } + } + + private async Task DownloadImageIfMissingAsync( + LauncherPaths paths, + 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, modificationName)); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + imageDirectory, + "Cached catalog image directories must not contain reparse points."); + string destinationFilePath = ModificationImageCachePath.ResolveRemoteImagePath( + paths, + modificationName, + fileName, + sourceUri); + await _assetDownloader.DownloadIfMissingAsync( + sourceUri, + destinationFilePath, + cancellationToken).ConfigureAwait(false); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + imageDirectory, + "Cached catalog image directories must not contain reparse points."); + _ = 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..6af90f9f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentCatalogService.cs @@ -0,0 +1,534 @@ +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; + + private readonly ILauncherContentStateStore _contentStateStore; + + private readonly RemoteLauncherCatalogClient _remoteCatalogClient; + + private readonly LauncherCatalogImageCache _imageCache; + + private readonly LauncherLocalContentReconciler _localContentReconciler; + + private readonly ILogger _logger; + + private CatalogSessionState _state = new(); + + /// + /// Serializes asynchronous catalog mutation so in-flight work cannot cross a game-session boundary. + /// + private readonly SemaphoreSlim _catalogMutationGate = new(1, 1); + + public LauncherContentCatalogService( + ILauncherContentStateStore contentStateStore, + RemoteLauncherCatalogClient remoteCatalogClient, + LauncherCatalogImageCache imageCache, + LauncherLocalContentReconciler localContentReconciler, + 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)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + 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 _catalogMutationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + CatalogSessionState previousState = _state; + try + { + _state = new CatalogSessionState(request.Paths); + await InitializeDataCoreAsync(request, cancellationToken).ConfigureAwait(false); + } + catch + { + _state = previousState; + throw; + } + finally + { + _catalogMutationGate.Release(); + } + } + + public async Task ReadOriginalGameAddonsAndPatchesAsync(CancellationToken cancellationToken) + { + await _catalogMutationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await ReadOriginalGameAddonsAndPatchesCoreAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _catalogMutationGate.Release(); + } + } + + public async Task GetRepositoryModificationMetadataAsync( + string name, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + await _catalogMutationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + RemoteModificationManifest manifest = await GetRepositoryModificationManifestAsync( + name, + cancellationToken).ConfigureAwait(false); + return manifest.Content; + } + finally + { + _catalogMutationGate.Release(); + } + } + + public async Task AddRepositoryModificationAsync( + string name, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + await _catalogMutationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + 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; + } + 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; + } + + public void UninstallVersion(LauncherContentKey contentKey) + { + _localContentReconciler.DeleteVersion(contentKey, Paths); + UpdateLocalModificationsData(); + } + + public void DiscardVersion(LauncherContentKey contentKey) + { + _localContentReconciler.DeleteVersion(contentKey, Paths); + _state.Data.DeleteVersion(contentKey); + UpdateLocalModificationsData(); + } + + public void DiscardContent(LauncherContentKey contentKey) + { + _localContentReconciler.DeleteContent(contentKey, Paths); + _state.Data.DeleteContent(contentKey); + UpdateLocalModificationsData(); + } + + public async Task ReadPatchesAndAddonsForModAsync( + LauncherContentKey modificationKey, + CancellationToken cancellationToken) + { + await _catalogMutationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await ReadPatchesAndAddonsForModCoreAsync(modificationKey, cancellationToken) + .ConfigureAwait(false); + } + finally + { + _catalogMutationGate.Release(); + } + } + + public void UpdateLocalModificationsData() + { + _localContentReconciler.Reconcile(_state.Data, _state.DownloadedRepositoryContent, Paths); + } + + private void ReadLocalModsData() + { + _state.Data = LauncherContentStateMapper.ToLauncherData(_contentStateStore.Load(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 LauncherPaths Paths => + _state.Paths ?? throw new InvalidOperationException("Launcher content catalog has not been initialized."); + + /// + /// 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; + } + + Task reposPatchesTask = _remoteCatalogClient.ReadChildManifestsAsync( + repositoryData.OriginalGamePatchManifestUrls, + originalGameKey.Name, + cancellationToken); + Task reposAddonsTask = _remoteCatalogClient.ReadChildManifestsAsync( + repositoryData.OriginalGameAddonManifestUrls, + originalGameKey.Name, + cancellationToken); + RemoteChildManifestLoadResult[] childManifestLoads = + await Task.WhenAll(reposPatchesTask, reposAddonsTask).ConfigureAwait(false); + RemoteChildManifestLoadResult patchLoad = childManifestLoads[0]; + RemoteChildManifestLoadResult addonLoad = childManifestLoads[1]; + + foreach (LauncherContentVersion patch in patchLoad.ContentVersions) + { + _state.Data.AddOrUpdate(patch); + _state.DownloadedRepositoryContent.Add(patch.ContentKey); + } + + foreach (LauncherContentVersion addon in addonLoad.ContentVersions) + { + _state.Data.AddOrUpdate(addon); + _state.DownloadedRepositoryContent.Add(addon.ContentKey); + } + + if (patchLoad.Succeeded && addonLoad.Succeeded) + { + _state.DownloadedModificationInfo.Add(originalGameKey); + } + } + + /// + /// 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; + } + + Task reposPatchesTask = _remoteCatalogClient.ReadChildManifestsAsync( + modData.PatchManifestUrls, + parentContentName: null, + cancellationToken); + Task reposAddonsTask = _remoteCatalogClient.ReadChildManifestsAsync( + modData.AddonManifestUrls, + parentContentName: null, + cancellationToken); + RemoteChildManifestLoadResult[] childManifestLoads = + await Task.WhenAll(reposPatchesTask, reposAddonsTask).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(keyModification); + } + } + + /// + /// 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); + if (!result.ContainsKey(key)) + { + result.Add(key, manifest); + } + } + + return result; + } + + private void AddRemoteModificationManifest(RemoteModificationManifest manifest) + { + var key = LauncherContentKey.ForModificationName(manifest.Content.Name); + if (!_state.ModificationsAndAddons.ContainsKey(key)) + { + _state.ModificationsAndAddons.Add(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 sealed class CatalogSessionState + { + public CatalogSessionState(LauncherPaths? paths = null) + { + Paths = paths; + } + + public LauncherData Data { get; set; } = new(); + + public Dictionary ModificationsAndAddons { get; set; } = + new(); + + public Dictionary RepositoryMetadataCache { get; } = new(); + + public HashSet DownloadedModificationInfo { get; } = new(); + + public HashSet DownloadedRepositoryContent { get; } = new(); + + public LauncherContentVersion? Advertising { get; set; } + + public LauncherPaths? Paths { get; } + + public RemoteLauncherCatalog? RepositoryData { get; set; } + + public IReadOnlyList? RepositoryModificationNames { get; set; } + } + + private static int CountVersions(LauncherData data) + { + return data.Modifications.Sum(modification => modification.Versions.Count) + + data.Patches.Sum(modification => modification.Versions.Count) + + data.Addons.Sum(modification => modification.Versions.Count); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentStateMapper.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentStateMapper.cs new file mode 100644 index 00000000..c97a959f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentStateMapper.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Maps compact legacy-compatible launcher content state to and from the active catalog. +/// +internal static class LauncherContentStateMapper +{ + public static LauncherData ToLauncherData(LauncherContentState state) + { + ArgumentNullException.ThrowIfNull(state); + + var launcherData = new LauncherData(); + AddStoredVersions(launcherData, state.Modifications, ModificationType.Mod); + AddStoredVersions(launcherData, state.Addons, ModificationType.Addon); + AddStoredVersions(launcherData, state.Patches, ModificationType.Patch); + 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) + { + 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 = version.Installation.IsSelected, + ContentSourceKind = version.Installation.ContentSourceKind + }; + } + + private static void AddStoredVersions( + LauncherData launcherData, + IEnumerable entries, + ModificationType fallbackType) + { + foreach (LauncherContentEntryState entry in entries ?? Enumerable.Empty()) + { + LauncherContent? storedModification = null; + foreach (LauncherContentVersionState version in entry.ModificationVersions ?? + new List()) + { + LauncherContentVersion modificationVersion = ToModificationVersion(entry, version, fallbackType); + 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) + { + ModificationType contentType = ResolveContentType( + version.ModificationType, + entry.ModificationType, + fallbackType); + + var installation = new LauncherContentInstallation + { + Installed = version.Installed || entry.Installed, + IsSelected = entry.IsSelected && version.IsSelected, + ContentSourceKind = version.ContentSourceKind + }; + return new LauncherContentVersion(installation) + { + ModificationType = contentType, + Name = CoalesceStateText(version.Name, entry.Name), + Version = version.Version ?? string.Empty, + ParentContentName = CoalesceStateText(version.DependenceName, entry.DependenceName) + }; + } + + private static List ToEntryStates( + IEnumerable modifications, + ModificationType fallbackType) + { + var entries = new List(); + foreach (LauncherContent modification in modifications ?? Enumerable.Empty()) + { + 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 || + fallbackType == ModificationType.Mod && + version.EffectiveContentSourceKind is + ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile; + } + + private static LauncherContentVersionState ToVersionState( + LauncherContentVersion version, + ModificationType fallbackType, + bool entryIsSelected) + { + LauncherContentVersionState versionState = ToVersionState(version, fallbackType); + versionState.IsSelected = entryIsSelected && versionState.IsSelected; + return versionState; + } + + 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..e8afcb6a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherLocalContentReconciler.cs @@ -0,0 +1,177 @@ +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); + + 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 DeleteContent( + LauncherContentKey contentKey, + LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + _localContentService.DeleteContent(paths, contentKey); + } + + private 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.Modifications + .Concat(launcherData.Addons) + .Concat(launcherData.Patches) + .SelectMany(content => content.Versions) + .DistinctBy(version => version.ContentKey) + .ToList(); + + foreach (LauncherContentVersion version in contentVersions) + { + CountReconciliationResult(CheckContentExistence( + launcherData, + downloadedReposContent, + version, + paths, + installedVersionIds), ref markedNotInstalledCount, ref 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 is + ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile) + { + if (modificationVersion.Installation.Installed) + { + modificationVersion.Installation.Installed = false; + return (true, false); + } + } + else + { + launcherData.DeleteVersion(modificationVersion.ContentKey); + DeleteModificationImagesIfCardMissing(launcherData, modificationVersion.ContentKey, paths); + return (false, true); + } + + return (false, false); + } + + private static void CountReconciliationResult( + (bool MarkedNotInstalled, bool Removed) result, + ref int markedNotInstalledCount, + ref int removedCount) + { + if (result.MarkedNotInstalled) + { + markedNotInstalledCount++; + } + + if (result.Removed) + { + removedCount++; + } + } + + private void DeleteModificationImagesIfCardMissing( + LauncherData launcherData, + LauncherContentKey contentKey, + LauncherPaths paths) + { + _localContentService.DeleteImagesIfUnused(paths, contentKey, launcherData); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/RemoteLauncherCatalogClient.cs b/GenLauncherGO.Infrastructure/Mods/Services/RemoteLauncherCatalogClient.cs new file mode 100644 index 00000000..9e66c77a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/RemoteLauncherCatalogClient.cs @@ -0,0 +1,258 @@ +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 IRemoteYamlDocumentReader _yamlDocumentReader; + + private readonly ILogger _logger; + + 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); + if (!mods.ContainsKey(key)) + { + mods.Add(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); + + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + try + { + return await DownloadModDataAsync(reference, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning( + "Failed to download remote modification manifest for {ModificationName}: {FailureReason}.", + reference.Name, + exception.Message); + return null; + } + } + finally + { + semaphore.Release(); + } + } + + /// + /// Reads one child manifest while respecting the startup refresh concurrency limit. + /// + private async Task ReadChildManifestIfAvailableAsync( + string url, + string? parentContentName, + SemaphoreSlim semaphore, + CancellationToken cancellationToken) + { + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + try + { + return await ReadModificationYamlAsync( + url, + parentContentName, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning( + "Failed to read child modification manifest: {FailureReason}.", + exception.Message); + return null; + } + } + finally + { + semaphore.Release(); + } + } + + public async Task DownloadAdvertisingInfoAsync( + string manifestUrl, + CancellationToken cancellationToken) + { + try + { + return await ReadModificationYamlAsync( + manifestUrl, + parentContentName: null, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning( + "Failed to download advertising manifest: {FailureReason}.", + exception.Message); + } + + return null; + } + + private async Task DownloadModDataAsync( + RemoteCatalogModificationReference reference, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(reference); + + LauncherContentVersion modification = await ReadModificationYamlAsync( + reference.ManifestUrl, + parentContentName: 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..c46ba2f7 --- /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/ModificationImageCachePath.cs b/GenLauncherGO.Infrastructure/Mods/Support/ModificationImageCachePath.cs new file mode 100644 index 00000000..163daf47 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Support/ModificationImageCachePath.cs @@ -0,0 +1,85 @@ +using System; +using System.IO; +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 ContainmentFailure = + "Cached modification image paths must stay inside the launcher-owned image directory."; + + private const string ReparsePointFailure = + "Cached modification image paths must not contain reparse points."; + + public static string ResolveDirectory(LauncherPaths paths, string modificationName) + { + ArgumentNullException.ThrowIfNull(paths); + + return ResolvePath(paths, paths.GetModificationImagesDirectory(modificationName)); + } + + public static string ResolvePath(LauncherPaths paths, string imagePath) + { + ArgumentNullException.ThrowIfNull(paths); + + return FileSystemPathSafety.ResolveOwnedSubpath( + paths.ImagesDirectory, + imagePath, + ContainmentFailure, + ReparsePointFailure); + } + + public static string ResolveRemoteImagePath( + LauncherPaths paths, + string modificationName, + string imageBaseName, + Uri sourceUri) + { + ArgumentNullException.ThrowIfNull(paths); + + return ResolvePath( + paths, + paths.GetModificationImageFilePath( + 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)), + ContainmentFailure, + ReparsePointFailure); + } + + private static string GetRemoteImageFileName(string imageBaseName, Uri sourceUri) + { + ArgumentNullException.ThrowIfNull(sourceUri); + + string extension = Path.GetExtension(sourceUri.LocalPath); + if (!IsSupportedImageExtension(extension)) + { + extension = ".png"; + } + + return imageBaseName + extension; + } + + private static bool IsSupportedImageExtension(string extension) + { + return string.Equals(extension, ".png", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".jpg", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs b/GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs new file mode 100644 index 00000000..e9ca74b1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs @@ -0,0 +1,112 @@ +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, + }; + } + + private static IReadOnlyList ToModificationReferences( + IEnumerable? modificationReferences) + { + return (modificationReferences ?? Enumerable.Empty()) + .Select(reference => new RemoteCatalogModificationReference( + reference.ModName, + reference.ModLink, + ToStringList(reference.ModPatches), + ToStringList(reference.ModAddons))) + .ToList(); + } + + private static IReadOnlyList ToAdvertisingReferences( + IEnumerable? advertisingReferences) + { + return (advertisingReferences ?? Enumerable.Empty()) + .Select(reference => new RemoteAdvertisingReference( + reference.ModName, + reference.ModLink, + ToStringList(reference.ImagesData))) + .ToList(); + } + + private static IReadOnlyList ToStringList(IEnumerable? values) + { + return (values ?? Enumerable.Empty()) + .Where(value => value != null) + .ToList()!; + } + +} diff --git a/GenLauncherGO.Infrastructure/Persistence/Services/AtomicFileWriter.cs b/GenLauncherGO.Infrastructure/Persistence/Services/AtomicFileWriter.cs new file mode 100644 index 00000000..b0f36af7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Persistence/Services/AtomicFileWriter.cs @@ -0,0 +1,148 @@ +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); + } + } + } + + 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 paths must be rooted.", + "Atomic document directories must not contain reparse points."); + Directory.CreateDirectory(destinationDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationDirectory, + "Atomic document paths must be rooted.", + "Atomic document directories must not contain reparse points."); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + fullDestinationPath, + "Atomic document paths must be rooted.", + "Atomic document paths must not contain reparse points."); + + 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(encoderShouldEmitUTF8Identifier: false).GetBytes(contents); + using FileStream stream = new( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + FileOptions.WriteThrough); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(flushToDisk: true); + } + + private static async Task WriteTemporaryFileAsync( + string temporaryPath, + Func writeTemporaryFileAsync, + CancellationToken cancellationToken) + { + await using FileStream stream = new( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 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(flushToDisk: true); + } + + private static void CommitTemporaryFile(string temporaryPath, string destinationPath) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationPath, + "Atomic document paths must be rooted.", + "Atomic document paths must not contain reparse points."); + if (File.Exists(destinationPath)) + { + File.Replace(temporaryPath, destinationPath, destinationBackupFileName: null, ignoreMetadataErrors: true); + return; + } + + try + { + File.Move(temporaryPath, destinationPath); + } + catch (IOException) when (File.Exists(destinationPath)) + { + File.Replace(temporaryPath, destinationPath, destinationBackupFileName: null, ignoreMetadataErrors: true); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Persistence/Services/IAtomicFileWriter.cs b/GenLauncherGO.Infrastructure/Persistence/Services/IAtomicFileWriter.cs new file mode 100644 index 00000000..8df8d254 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Persistence/Services/IAtomicFileWriter.cs @@ -0,0 +1,32 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Persistence.Services; + +/// +/// Commits complete text documents 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); +} diff --git a/GenLauncherGO.Infrastructure/Persistence/Services/IYamlDocumentStore.cs b/GenLauncherGO.Infrastructure/Persistence/Services/IYamlDocumentStore.cs new file mode 100644 index 00000000..124574e5 --- /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..7d280fa5 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Persistence/Services/YamlDocumentStore.cs @@ -0,0 +1,85 @@ +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 string _documentFilePath; + + private readonly ILogger> _logger; + + private readonly IAtomicFileWriter _atomicFileWriter; + + 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 must be rooted.", + "YAML document paths must not contain reparse points."); + 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..3e82382a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GenLauncherGO.Tests")] diff --git a/GenLauncherGO.Infrastructure/Remote/Contracts/IRemoteAssetDownloader.cs b/GenLauncherGO.Infrastructure/Remote/Contracts/IRemoteAssetDownloader.cs new file mode 100644 index 00000000..9914e8f5 --- /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/HttpRemoteAssetDownloader.cs b/GenLauncherGO.Infrastructure/Remote/HttpRemoteAssetDownloader.cs new file mode 100644 index 00000000..69abfc2f --- /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.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 ILogger _logger; + + public HttpRemoteAssetDownloader( + IResumableFileDownloader fileDownloader, + ILogger logger) + { + _fileDownloader = fileDownloader ?? throw new ArgumentNullException(nameof(fileDownloader)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Downloads a remote asset to a temporary file and atomically moves it into place when the final file is missing. + /// + public async Task DownloadIfMissingAsync( + Uri sourceUri, + string destinationFilePath, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(sourceUri); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationFilePath); + + if (File.Exists(destinationFilePath)) + { + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(destinationFilePath) ?? "."); + string temporaryFilePath = destinationFilePath + ".download"; + if (File.Exists(temporaryFilePath)) + { + File.Delete(temporaryFilePath); + _logger.LogInformation( + "Deleted stale remote asset download file {FileName}.", + Path.GetFileName(temporaryFilePath)); + } + + await _fileDownloader.DownloadFileAsync( + new DownloadFileRequest(sourceUri, temporaryFilePath, Resume: false), + null, + cancellationToken).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + if (File.Exists(destinationFilePath)) + { + File.Delete(temporaryFilePath); + return; + } + + File.Move(temporaryFilePath, destinationFilePath); + _logger.LogInformation( + "Downloaded remote asset {FileName} from {Host}.", + Path.GetFileName(destinationFilePath), + sourceUri.Host); + } +} diff --git a/GenLauncherGO.Infrastructure/Remote/HttpRemoteConnectionProbe.cs b/GenLauncherGO.Infrastructure/Remote/HttpRemoteConnectionProbe.cs new file mode 100644 index 00000000..8f8fe862 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/HttpRemoteConnectionProbe.cs @@ -0,0 +1,82 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Remote; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Remote; + +/// +/// Checks remote HTTP endpoint connectivity. +/// +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 SendProbeAsync(endpointUri, HttpMethod.Head, cancellationToken).ConfigureAwait(false) || + await SendProbeAsync(endpointUri, HttpMethod.Get, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException ex) + { + _logger.LogWarning( + ex, + "Remote connection probe failed for {Scheme}://{Host}.", + endpointUri.Scheme, + endpointUri.Host); + return false; + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning( + ex, + "Remote connection probe timed out for {Scheme}://{Host}.", + endpointUri.Scheme, + endpointUri.Host); + return false; + } + } + + private async Task SendProbeAsync( + Uri endpointUri, + HttpMethod httpMethod, + CancellationToken cancellationToken) + { + using HttpRequestMessage request = new(httpMethod, endpointUri); + using HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + if (httpMethod == HttpMethod.Head && + (response.StatusCode == HttpStatusCode.MethodNotAllowed || + response.StatusCode == HttpStatusCode.NotImplemented)) + { + return false; + } + + return response.IsSuccessStatusCode; + } +} diff --git a/GenLauncherGO.Infrastructure/Remote/HttpRemoteYamlDocumentReader.cs b/GenLauncherGO.Infrastructure/Remote/HttpRemoteYamlDocumentReader.cs new file mode 100644 index 00000000..dfe2183e --- /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 ex) + { + _logger.LogDebug( + ex, + "Failed to read remote YAML document from {Scheme}://{Host}.", + documentUri.Scheme, + documentUri.Host); + throw; + } + } +} diff --git a/GenLauncherGO.Infrastructure/Remote/SharedHttpClientFactory.cs b/GenLauncherGO.Infrastructure/Remote/SharedHttpClientFactory.cs new file mode 100644 index 00000000..fefd3da6 --- /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..d2bae2e6 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Models/LauncherPreferencesDocument.cs @@ -0,0 +1,110 @@ +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 +{ + private int? _schemaVersion; + + /// + /// 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 => _schemaVersion; + set + { + _schemaVersion = 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 UseEnglishLanguage { 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 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..aea4fe0f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Services/PreferencesService.cs @@ -0,0 +1,124 @@ +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 _schemaDocumentStore; + + private readonly IYamlDocumentStore _documentStore; + + private readonly IYamlDocumentStore _legacyDocumentStore; + + private LauncherPreferences _current; + + 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 => _current; + + public void Update(LauncherPreferences preferences) + { + ArgumentNullException.ThrowIfNull(preferences); + + LauncherPreferences normalizedPreferences = LauncherPreferencesDocumentMapper.Normalize(preferences); + if (normalizedPreferences == _current) + { + return; + } + + try + { + SavePreferences(normalizedPreferences); + } + catch (Exception exception) + { + throw new LauncherPreferencesPersistenceException(exception); + } + + _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) + { + try + { + SavePreferences(preferences); + } + catch (Exception exception) + { + throw new LauncherPreferencesPersistenceException(exception); + } + + return preferences; + } + + private void SavePreferences(LauncherPreferences preferences) + { + _documentStore.Save(LauncherPreferencesDocumentMapper.ToDocument(preferences)); + } +} diff --git a/GenLauncherGO.Infrastructure/Settings/Support/LauncherPreferencesDocumentMapper.cs b/GenLauncherGO.Infrastructure/Settings/Support/LauncherPreferencesDocumentMapper.cs new file mode 100644 index 00000000..d4efb2ef --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Support/LauncherPreferencesDocumentMapper.cs @@ -0,0 +1,307 @@ +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, + UseEnglishLanguage = normalized.Shared.UseEnglishLanguage, + }, + 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, + UseEnglishLanguage = shared.UseEnglishLanguage, + }; + } + + 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, + 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, + 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, + CustomGameClients = NormalizeCustomExecutables( + preferences.CustomGameClients, + GetBuiltInGameClientNames(game)), + CustomWorldBuilders = NormalizeCustomExecutables( + preferences.CustomWorldBuilders, + GetBuiltInWorldBuilderNames(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, + IReadOnlySet 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 IReadOnlySet GetBuiltInGameClientNames(SupportedGame game) + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase) + { + LauncherFileSystemLayout.GetCommunityGameExecutableName(game), + }; + if (game == SupportedGame.ZeroHour) + { + names.Add(LauncherFileSystemLayout.GeneralsOnlineExecutableFileName); + } + + return names; + } + + private static IReadOnlySet GetBuiltInWorldBuilderNames(SupportedGame game) + { + return new HashSet(StringComparer.OrdinalIgnoreCase) + { + LauncherFileSystemLayout.VanillaWorldBuilderExecutableFileName, + LauncherFileSystemLayout.GetCommunityWorldBuilderExecutableName(game), + }; + } + + 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..6e5c7aed --- /dev/null +++ b/GenLauncherGO.Infrastructure/Shell/Services/WindowsLauncherShellService.cs @@ -0,0 +1,157 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +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 System.Security.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..2089b8f3 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/FileSystemLauncherPathResolver.cs @@ -0,0 +1,69 @@ +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.LogInformation("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) + { + OwnedDirectoryTree.PrepareEmpty(paths.OwnedGameDataDirectory, paths.TempDirectory); + } + + _logger.LogInformation( + "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..c0d71440 --- /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..bac604e7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationRegistry.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security; +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 Steam and the EA App. Preserve that external contract and order. + (GeneralsKey, "installPath"), + (GeneralsKey, "InstallPath"), + (FirstDecadeKey, "gr_folder"), + }; + + private static readonly (string KeyName, string ValueName)[] _zeroHourProbes = + { + (ZeroHourSteamKey, "installPath"), + (ZeroHourEaKey, "InstallPath"), + (FirstDecadeKey, "zh_folder"), + }; + + 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 new ArgumentOutOfRangeException(nameof(game), game, "A supported game is required."), + }; + + 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, writable: 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 => + string.Equals(existing, candidate, StringComparison.OrdinalIgnoreCase))) + { + 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..5f3d23c2 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationService.cs @@ -0,0 +1,255 @@ +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 supported installations and discovers candidates from the Windows registry. +/// +public sealed class WindowsGameInstallationService : IGameInstallationService +{ + private static readonly SupportedGame[] _supportedGames = + { + SupportedGame.ZeroHour, + SupportedGame.Generals, + }; + + private readonly IGameInstallationRegistry _registry; + private readonly ILogger _logger; + + 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 _supportedGames) + { + if (HasRequiredFiles(game, directory.FullName)) + { + return new GameInstallationLocation(game, directory.FullName); + } + } + } + + return null; + } + + public GameInstallationValidationResult Validate( + SupportedGame game, + string? directory, + string executableDirectory) + { + EnsureSupported(game); + ArgumentException.ThrowIfNullOrWhiteSpace(executableDirectory); + + if (string.IsNullOrWhiteSpace(directory)) + { + return Invalid(GameInstallationValidationFailure.PathMissing); + } + + string candidatePath; + try + { + if (!Path.IsPathFullyQualified(directory.Trim())) + { + return Invalid(GameInstallationValidationFailure.PathUnavailable); + } + + candidatePath = LexicalPath.NormalizeFullPath(directory.Trim()); + } + catch (Exception exception) when (exception is ArgumentException or IOException or NotSupportedException) + { + return Invalid(GameInstallationValidationFailure.PathUnavailable); + } + + if (!Directory.Exists(candidatePath)) + { + return Invalid(GameInstallationValidationFailure.DirectoryNotFound); + } + + try + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + candidatePath, + "Game installation paths must be rooted.", + "Game installation paths must not traverse reparse points."); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + executableDirectory, + "Launcher paths must be rooted.", + "Launcher paths must not traverse reparse points."); + } + catch (InvalidDataException) + { + return 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 Invalid(GameInstallationValidationFailure.PathUnavailable); + } + + try + { + string canonicalGamePath = PhysicalDirectoryPath.ResolveExisting(candidatePath); + SupportedGame otherGame = game == SupportedGame.Generals + ? SupportedGame.ZeroHour + : SupportedGame.Generals; + if (!HasRequiredFiles(game, canonicalGamePath) || + HasRequiredFiles(otherGame, canonicalGamePath)) + { + return Invalid(GameInstallationValidationFailure.RequiredFilesMissing); + } + + string canonicalExecutablePath = PhysicalDirectoryPath.ResolveExisting(executableDirectory); + if (LexicalPath.IsPathInDirectory(canonicalExecutablePath, canonicalGamePath)) + { + return Invalid(GameInstallationValidationFailure.LauncherLocationOverlapsGame); + } + + string sharedDataPath = Path.Combine( + canonicalExecutablePath, + LauncherFileSystemLayout.LauncherDataFolderName); + if (LexicalPath.IsPathInDirectory(canonicalGamePath, sharedDataPath)) + { + return Invalid(GameInstallationValidationFailure.UnsafeFileSystemPath); + } + + 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 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 _supportedGames) + { + 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 GameInstallationValidationResult Invalid(GameInstallationValidationFailure failure) + { + return GameInstallationValidationResult.Invalid(failure); + } + + private static void EnsureSupported(SupportedGame game) + { + if (game is not SupportedGame.Generals and not SupportedGame.ZeroHour) + { + throw new ArgumentOutOfRangeException(nameof(game), game, "A supported game is required."); + } + } + + private static bool HasRequiredFiles(SupportedGame game, string directory) + { + if (!File.Exists(Path.Combine(directory, LauncherFileSystemLayout.BinkLibraryFileName))) + { + return false; + } + + return game switch + { + SupportedGame.Generals => + HasGameFile(directory, LauncherFileSystemLayout.GeneralsWindowArchiveFileName) && + File.Exists(Path.Combine( + directory, + LauncherFileSystemLayout.GeneralsCommunityExecutableFileName)), + SupportedGame.ZeroHour => + HasGameFile(directory, LauncherFileSystemLayout.ZeroHourWindowArchiveFileName) && + (File.Exists(Path.Combine( + directory, + LauncherFileSystemLayout.ZeroHourCommunityExecutableFileName)) || + File.Exists(Path.Combine( + directory, + LauncherFileSystemLayout.GeneralsOnlineExecutableFileName))), + _ => false, + }; + } + + private static bool HasGameFile(string directory, string fileName) + { + return File.Exists(Path.Combine(directory, fileName)); + } +} diff --git a/GenLauncherGO.Infrastructure/Startup/WindowsLauncherHostEnvironmentService.cs b/GenLauncherGO.Infrastructure/Startup/WindowsLauncherHostEnvironmentService.cs new file mode 100644 index 00000000..c98abbe3 --- /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(initiallyOwned: true, instanceName, out bool createdNew); + if (createdNew) + { + return new MutexSingleInstanceGuard(mutex, isAcquired: true); + } + + mutex.Dispose(); + if (retryDelay > TimeSpan.Zero) + { + _waitBeforeSingleInstanceRetry(retryDelay); + } + + mutex = new Mutex(initiallyOwned: true, instanceName, out createdNew); + if (createdNew) + { + return new MutexSingleInstanceGuard(mutex, isAcquired: 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, isAcquired: 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..0d615f19 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/HttpDownloadFileMetadataReader.cs @@ -0,0 +1,129 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +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; + +/// +/// 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; + private readonly ILogger _logger; + + public HttpDownloadFileMetadataReader( + HttpClient? httpClient = null, + ILogger? logger = null) + { + _httpClient = httpClient ?? _sharedHttpClient; + _logger = logger ?? NullLogger.Instance; + } + + public async Task ReadMetadataAsync( + Uri downloadUri, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(downloadUri); + + try + { + DownloadFileMetadata? metadata = await TryReadMetadataAsync( + downloadUri, + HttpMethod.Head, + cancellationToken).ConfigureAwait(false); + if (metadata is not null) + { + return metadata; + } + + metadata = await TryReadMetadataAsync( + downloadUri, + HttpMethod.Get, + cancellationToken).ConfigureAwait(false); + if (metadata is not null) + { + return metadata; + } + + _logger.LogWarning( + "Remote download metadata did not include a file name for {Scheme}://{Host}.", + downloadUri.Scheme, + downloadUri.Host); + throw new InvalidOperationException( + "Download link is incorrect, please contact modification creator and try again later."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning( + "Failed to read remote download metadata from {Scheme}://{Host}; failure type: {FailureType}.", + downloadUri.Scheme, + downloadUri.Host, + exception.GetType().Name); + throw; + } + } + + private async Task TryReadMetadataAsync( + Uri downloadUri, + HttpMethod httpMethod, + CancellationToken cancellationToken) + { + using HttpRequestMessage request = new(httpMethod, downloadUri); + using HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + if (httpMethod == HttpMethod.Head && + (response.StatusCode == HttpStatusCode.MethodNotAllowed || + response.StatusCode == HttpStatusCode.NotImplemented)) + { + return null; + } + + response.EnsureSuccessStatusCode(); + + string? fileName = response.Content.Headers.ContentDisposition?.FileNameStar; + if (string.IsNullOrWhiteSpace(fileName)) + { + fileName = response.Content.Headers.ContentDisposition?.FileName; + } + + if (string.IsNullOrWhiteSpace(fileName)) + { + return null; + } + + return new DownloadFileMetadata( + downloadUri, + SanitizeFileName(fileName), + response.Content.Headers.ContentLength); + } + + private static string SanitizeFileName(string fileName) + { + string sanitizedFileName = fileName.Trim('"').Replace("\\", string.Empty).Replace("/", string.Empty); + if (string.IsNullOrWhiteSpace(sanitizedFileName)) + { + throw new InvalidOperationException( + "Download link is incorrect, please contact modification creator and try again later."); + } + + return sanitizedFileName; + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Clients/MinioClientFactory.cs b/GenLauncherGO.Infrastructure/Updating/Clients/MinioClientFactory.cs new file mode 100644 index 00000000..2d2e5b99 --- /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..4d96a84e --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/MinioS3ObjectManifestReader.cs @@ -0,0 +1,116 @@ +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 = new(); + 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.LogInformation( + "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) + { + 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..2ada3923 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/ResumableHttpFileDownloader.cs @@ -0,0 +1,402 @@ +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 HttpClient _httpClient; + private readonly ILogger _logger; + private readonly int _bufferSize; + private readonly int _maxAttempts; + private readonly TimeSpan _idleTimeout; + private readonly TimeSpan _progressReportInterval; + private readonly TimeSpan _initialRetryDelay; + 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 ex) when (IsRetriable(ex) && attempt < _maxAttempts) + { + lastException = ex; + _logger.LogWarning( + ex, + "Download attempt {Attempt} failed for {FileName}; retrying.", + attempt, + Path.GetFileName(destinationFilePath)); + + await Task.Delay(GetRetryDelay(attempt), cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (IsRetriable(ex)) + { + lastException = ex; + break; + } + } + + throw new IOException( + string.Format( + CultureInfo.InvariantCulture, + "Download failed after {0} attempts.", + _maxAttempts), + lastException); + } + + private async Task DownloadAttemptAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + long existingBytes = GetExistingBytes(request); + if (request.ExpectedBytes.HasValue && existingBytes == request.ExpectedBytes.Value) + { + ReportProgress(progress, request.ExpectedBytes, existingBytes); + return; + } + + using HttpRequestMessage message = new(HttpMethod.Get, request.SourceUri); + if (existingBytes > 0) + { + message.Headers.Range = new RangeHeaderValue(existingBytes, null); + } + + using HttpResponseMessage response = await _httpClient.SendAsync( + message, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && + request.ExpectedBytes.HasValue && + existingBytes == request.ExpectedBytes.Value) + { + ReportProgress(progress, request.ExpectedBytes, existingBytes); + return; + } + + 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 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 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 ValueTask WaitWhilePausedAsync( + PackageDownloadPauseController? pauseController, + CancellationToken cancellationToken) + { + return pauseController?.WaitWhilePausedAsync(cancellationToken) ?? ValueTask.CompletedTask; + } + + private static bool IsRetriable(Exception ex) + { + return ex is HttpRequestException or IOException or TimeoutException || + ex is 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..71fab252 --- /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..d4591a07 --- /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..63899a2e --- /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( + Uri sourceUri, + 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..1fcf8379 --- /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..e03c86ca --- /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..d0b20d4f --- /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..c9e9d967 --- /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/Md5FileHashService.cs b/GenLauncherGO.Infrastructure/Updating/Services/Md5FileHashService.cs new file mode 100644 index 00000000..232719ad --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/Md5FileHashService.cs @@ -0,0 +1,51 @@ +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..ecd8402d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/PackageDownloadService.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +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.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 static readonly HashSet _extensionsToCheckHash = + new(StringComparer.OrdinalIgnoreCase) + { + ".w3d", + BigFileVariantPath.BigExtension, + ".bik", + BigFileVariantPath.GibExtension, + ".dds", + ".tga", + ".ini", + ".scb", + ".wnd", + ".csf", + ".str", + }; + + private readonly ISingleFilePackageUpdater _singleFilePackageUpdater; + private readonly IS3PackageUpdater _s3PackageUpdater; + private readonly IS3ObjectManifestReader _s3ObjectManifestReader; + private readonly LauncherRuntimePathContext _runtimePathContext; + private readonly ILogger _logger; + + public PackageDownloadService( + ISingleFilePackageUpdater singleFilePackageUpdater, + IS3PackageUpdater s3PackageUpdater, + IS3ObjectManifestReader s3ObjectManifestReader, + LauncherRuntimePathContext runtimePathContext, + ILogger logger) + { + _singleFilePackageUpdater = singleFilePackageUpdater ?? + throw new ArgumentNullException(nameof(singleFilePackageUpdater)); + _s3PackageUpdater = s3PackageUpdater ?? throw new ArgumentNullException(nameof(s3PackageUpdater)); + _s3ObjectManifestReader = s3ObjectManifestReader ?? + throw new ArgumentNullException(nameof(s3ObjectManifestReader)); + _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; + + if (ShouldUseSingleFileDownload(version)) + { + await DownloadSingleFilePackageAsync( + version, + paths, + monotonicProgress, + pauseController, + cancellationToken).ConfigureAwait(false); + } + else + { + await DownloadS3PackageAsync( + modification, + version, + paths, + monotonicProgress, + pauseController, + cancellationToken).ConfigureAwait(false); + } + + _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.LogInformation( + 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, + LauncherPaths paths, + IProgress? progress, + PackageDownloadPauseController pauseController, + CancellationToken cancellationToken) + { + string downloadUrl = DownloadLinkResolver.ResolveDirectDownloadLink( + version.SimpleDownloadLink); + 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( + new Uri(downloadUrl, UriKind.Absolute), + packagePaths, + progress, + cancellationToken, + pauseController).ConfigureAwait(false); + } + + private async Task DownloadS3PackageAsync( + LauncherContent modification, + LauncherContentVersion version, + LauncherPaths paths, + IProgress? progress, + PackageDownloadPauseController pauseController, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Starting S3 package download for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + + S3ObjectManifestRequest source = S3CatalogDefaults.CreateManifestRequest(version); + IReadOnlyList repositoryFilesInfo = + await _s3ObjectManifestReader.ReadManifestAsync(source, cancellationToken).ConfigureAwait(false); + await pauseController.WaitWhilePausedAsync(cancellationToken).ConfigureAwait(false); + LauncherContentVersion? latestInstalledVersion = modification.Versions + .OrderBy(version => version) + .Where(version => version.Installation.Installed) + .LastOrDefault(); + PackageUpdatePathSet packagePaths = CreatePackagePaths( + paths, + version, + latestInstalledVersion); + + await _s3PackageUpdater.UpdateAsync( + new S3PackageUpdateRequest( + repositoryFilesInfo, + source, + packagePaths, + _extensionsToCheckHash), + progress, + cancellationToken, + pauseController).ConfigureAwait(false); + } + + private 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 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 static bool ShouldUseSingleFileDownload(LauncherContentVersion version) + { + return version.EffectiveContentSourceKind != ContentSourceKind.ManagedS3; + } + + 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/RemotePackageSizeResolver.cs b/GenLauncherGO.Infrastructure/Updating/Services/RemotePackageSizeResolver.cs new file mode 100644 index 00000000..3a609999 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/RemotePackageSizeResolver.cs @@ -0,0 +1,138 @@ +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 session-caches package sizes from HTTP or S3-compatible metadata without downloading payloads. +/// +internal sealed class RemotePackageSizeResolver : IRemotePackageSizeResolver +{ + private readonly IDownloadFileMetadataReader _downloadFileMetadataReader; + + private readonly IS3ObjectManifestReader _s3ObjectManifestReader; + + private readonly ILogger _logger; + + private readonly object _cacheSync = new(); + + private readonly Dictionary _cache = new(); + + public RemotePackageSizeResolver( + 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 (_cache.TryGetValue(cacheKey, out long? cachedSize)) + { + return cachedSize; + } + } + + long? totalBytes; + try + { + totalBytes = await ResolveTotalBytesAsync(version, cancellationToken).ConfigureAwait(false); + } + 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) + { + _cache[cacheKey] = totalBytes; + } + + return totalBytes; + } + + private async Task ResolveTotalBytesAsync( + LauncherContentVersion version, + CancellationToken cancellationToken) + { + switch (version.EffectiveContentSourceKind) + { + case ContentSourceKind.ManagedS3: + IReadOnlyList manifest = + await _s3ObjectManifestReader.ReadManifestAsync( + S3CatalogDefaults.CreateManifestRequest(version), + cancellationToken).ConfigureAwait(false); + ulong totalSize = 0; + foreach (RemoteFileManifestEntry entry in manifest) + { + totalSize = checked(totalSize + entry.Size); + } + + return checked((long)totalSize); + + case ContentSourceKind.ManagedSingleFile: + Uri downloadUri = DownloadLinkResolver.ResolveDownloadUri(version.SimpleDownloadLink); + DownloadFileMetadata metadata = await _downloadFileMetadataReader.ReadMetadataAsync( + downloadUri, + cancellationToken).ConfigureAwait(false); + return metadata.TotalBytes is >= 0 ? metadata.TotalBytes : null; + + default: + _logger.LogDebug( + "Package size is unavailable for {ContentIdentity} with unsupported source {SourceKind}.", + version.ContentKey.ToStableString(), + version.EffectiveContentSourceKind); + return null; + } + } + + private readonly record struct CacheKey( + LauncherContentKey ContentKey, + ContentSourceKind SourceKind, + string S3Host, + string S3Bucket, + string S3Folder, + string DirectDownloadLink) + { + public static CacheKey Create(LauncherContentVersion version) + { + return new CacheKey( + version.ContentKey, + version.EffectiveContentSourceKind, + version.S3HostLink, + version.S3BucketName, + version.S3FolderName, + version.SimpleDownloadLink); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/S3PackageUpdater.cs b/GenLauncherGO.Infrastructure/Updating/Services/S3PackageUpdater.cs new file mode 100644 index 00000000..373575e4 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/S3PackageUpdater.cs @@ -0,0 +1,566 @@ +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.Mods.Models; +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; + +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 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.LogInformation( + "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 WaitWhilePausedAsync(pauseController, cancellationToken).ConfigureAwait(false); + await DownloadFilesAsync( + request.Files, + request.Source, + temporaryFolderPath, + request.HashCheckedExtensions, + progress, + pauseController, + cancellationToken).ConfigureAwait(false); + + await WaitWhilePausedAsync(pauseController, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + PackageStagingFolderCleaner.PruneToManifest( + ownedPaths.TemporaryPath, + request.Files, + _logger, + cancellationToken); + + await WaitWhilePausedAsync(pauseController, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + PackageInstallFolderReplacer.Replace( + ownedPaths.TemporaryPath, + ownedPaths.InstalledPath, + ownedPaths.BackupPath, + _logger); + PackageStagingFolderCleaner.DeleteEmptyPackageParents(ownedPaths.TemporaryPath, _logger); + _logger.LogInformation( + "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 must be rooted.", + "Installed package folder contains a linked path and cannot be repaired safely."); + Directory.CreateDirectory(installedFolderPath); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + installedFolderPath, + "Installed package paths must be rooted.", + "Installed package folder contains a linked path and cannot be repaired safely."); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + installedFolderPath, + "Installed package folder contains a linked path and cannot be repaired safely."); + _logger.LogInformation( + "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, + pauseController: null, + cancellationToken: cancellationToken).ConfigureAwait(false); + _logger.LogInformation( + "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); + long totalDownloadSize = downloadWorkItems.Sum(download => download.BytesToDownload); + var progressState = new PackageProgressTracker(totalDownloadSize); + if (downloadWorkItems.Count == 0) + { + progress?.Report(new PackageUpdateProgress(0, 0, 100, null)); + } + + using SemaphoreSlim downloadSlots = new(MaxConcurrentFileDownloads); + IMinioClient client = Clients.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(); + + await Task.WhenAll(downloadTasks).ConfigureAwait(false); + } + + /// + /// 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 = new(); + 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 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, + Resume: true, + PauseController: pauseController), + downloadProgress, + cancellationToken).ConfigureAwait(false); + + await WaitWhilePausedAsync(pauseController, cancellationToken).ConfigureAwait(false); + BigFileVariantPath.ConvertBigFileToGib(destinationFilePath); + + if (await CheckFileSuccessDownloadAsync( + file, + destinationFilePath, + hashCheckedExtensions, + cancellationToken).ConfigureAwait(false)) + { + ReportFileProgress( + progressState, + progress, + progressItemName, + expectedTransferBytes, + forceReport: true); + return; + } + + if (attempt == MaxHashAttempts) + { + throw new IOException("Hash sum mismatch detected after repeated download attempts."); + } + + // Account for the completed but invalid transfer without publishing a false terminal 100% report. + progressState.CompleteItemSilently(progressItemName, expectedTransferBytes); + _logger.LogWarning( + "Hash validation failed for {FileName}; retrying download attempt {NextAttempt}.", + file.FileName, + attempt + 1); + DeleteFailedFile(destinationFilePath); + await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false); + } + } + + private static ValueTask WaitWhilePausedAsync( + PackageDownloadPauseController? pauseController, + CancellationToken cancellationToken) + { + return pauseController?.WaitWhilePausedAsync(cancellationToken) ?? ValueTask.CompletedTask; + } + + 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 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.LogInformation( + "Deleted failed downloaded file {FileName}.", + Path.GetFileName(destinationFilePath)); + } + + string gibFilePath = BigFileVariantPath.GetGibVariantPath(destinationFilePath); + if (File.Exists(gibFilePath)) + { + File.Delete(gibFilePath); + _logger.LogInformation( + "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 (!string.Equals( + LexicalPath.NormalizeFullPath(packageRoot), + LexicalPath.NormalizeFullPath(destinationDirectory), + StringComparison.OrdinalIgnoreCase)) + { + OwnedDirectoryTree.EnsureExists(packageRoot, destinationDirectory); + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationFilePath, + "Package file paths must be rooted.", + "Package file path contains a linked segment and cannot be updated safely."); + } + + /// + /// 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.ResolvePath(packageRoot, file.FileName); + destinationPath = BigFileVariantPath.GetInstalledPath(destinationPath); + + 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..3cc97f0e --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/SingleFilePackageUpdater.cs @@ -0,0 +1,153 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Archives; +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; + private readonly IDownloadFileMetadataReader _metadataReader; + + public SingleFilePackageUpdater( + IResumableFileDownloader fileDownloader, + IDownloadFileMetadataReader metadataReader, + IArchiveExtractor archiveExtractor, + ILogger logger) + { + _fileDownloader = fileDownloader ?? throw new ArgumentNullException(nameof(fileDownloader)); + _metadataReader = metadataReader ?? throw new ArgumentNullException(nameof(metadataReader)); + _archiveExtractor = archiveExtractor ?? throw new ArgumentNullException(nameof(archiveExtractor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Downloads a single remote package file, optionally extracts it, removes the downloaded archive, and stages the + /// temporary folder into the installed package location. + /// + public async Task UpdateAsync( + Uri sourceUri, + PackageUpdatePathSet paths, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + ArgumentNullException.ThrowIfNull(sourceUri); + ArgumentNullException.ThrowIfNull(paths); + + await WaitWhilePausedAsync(pauseController, cancellationToken).ConfigureAwait(false); + string temporaryFolderPath = paths.TemporaryPath.FullPath; + PackageStagingFolderCleaner.ClearDirectory(paths.TemporaryPath, _logger); + _logger.LogInformation( + "Starting single-file package update from {Host}.", + sourceUri.Host); + + DownloadFileMetadata metadata = await _metadataReader.ReadMetadataAsync( + sourceUri, + cancellationToken).ConfigureAwait(false); + await WaitWhilePausedAsync(pauseController, cancellationToken).ConfigureAwait(false); + + string destinationFilePath = ResolveMetadataFilePath( + temporaryFolderPath, + metadata.FileName); + bool extractionRequired = ArchiveFileSupport.IsSupported(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, + Resume: true, + PauseController: pauseController), + downloadProgress, + cancellationToken).ConfigureAwait(false); + + await WaitWhilePausedAsync(pauseController, cancellationToken).ConfigureAwait(false); + if (extractionRequired) + { + await Task.Run( + () => _archiveExtractor.ExtractToDirectory( + destinationFilePath, + temporaryFolderPath, + convertBigFilesToGib: true, + cancellationToken), + cancellationToken).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + if (File.Exists(destinationFilePath)) + { + File.Delete(destinationFilePath); + _logger.LogInformation( + "Deleted downloaded archive {FileName} after extraction.", + Path.GetFileName(destinationFilePath)); + } + } + + await WaitWhilePausedAsync(pauseController, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + _logger); + PackageStagingFolderCleaner.DeleteEmptyPackageParents(paths.TemporaryPath, _logger); + _logger.LogInformation("Completed single-file package update."); + } + + private static ValueTask WaitWhilePausedAsync( + PackageDownloadPauseController? pauseController, + CancellationToken cancellationToken) + { + return pauseController?.WaitWhilePausedAsync(cancellationToken) ?? ValueTask.CompletedTask; + } + + /// + /// Resolves an HTTP metadata file name as one direct child of the owned staging folder. + /// + private static string ResolveMetadataFilePath( + string temporaryFolderPath, + string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + throw new InvalidDataException( + "Remote package metadata did not provide a safe direct file name."); + } + + string trimmedFileName = fileName.Trim(); + if (trimmedFileName is "." or ".." || + trimmedFileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || + trimmedFileName.Contains(Path.DirectorySeparatorChar) || + trimmedFileName.Contains(Path.AltDirectorySeparatorChar)) + { + throw new InvalidDataException( + "Remote package metadata did not provide a safe direct file name."); + } + + return ManifestPathResolver.ResolvePath(temporaryFolderPath, trimmedFileName); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/DownloadLinkResolver.cs b/GenLauncherGO.Infrastructure/Updating/Support/DownloadLinkResolver.cs new file mode 100644 index 00000000..1fa35ff2 --- /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"); + } + + 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"); + } + + List linkParts = [.. link.Replace("https://onedrive.live.com/?", string.Empty).Split('&')]; + string? cid = linkParts.Where(t => t.Contains("cid=", StringComparison.Ordinal)) + .Select(t => t.Replace("cid=", string.Empty)) + .FirstOrDefault(); + string? authKey = linkParts.Where(t => t.Contains("authkey=", StringComparison.Ordinal)) + .Select(t => t.Replace("authkey=", string.Empty)) + .FirstOrDefault(); + string? resid = linkParts.Where(t => + t.Contains("id=", StringComparison.Ordinal) && + !t.Contains("cid=", StringComparison.Ordinal)) + .Select(t => t.Replace("id=", string.Empty)) + .FirstOrDefault(); + + return string.Format( + CultureInfo.InvariantCulture, + "https://onedrive.live.com/download?cid={0}&resid={1}&authkey={2}", + cid, + resid, + authKey); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/InlineProgress.cs b/GenLauncherGO.Infrastructure/Updating/Support/InlineProgress.cs new file mode 100644 index 00000000..88b8def7 --- /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..96e75416 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/MonotonicPackageProgress.cs @@ -0,0 +1,65 @@ +using System; +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 object _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..86029fa5 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/PackageInstallFolderReplacer.cs @@ -0,0 +1,302 @@ +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; + string? parentDirectory = Path.GetDirectoryName(installedFolderPath); + if (!string.IsNullOrWhiteSpace(parentDirectory)) + { + FileSystemPathSafety.ResolveOwnedSubpath( + installedPath.OwnerRoot, + parentDirectory, + "Installed package paths must stay inside launcher-owned content.", + "The installed package folder cannot contain a reparse point."); + Directory.CreateDirectory(parentDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + parentDirectory, + "Installed package paths must be rooted.", + "The installed package folder cannot contain a reparse point."); + } + + EnsureDirectoryPathHasNoReparsePoints( + installedPath, + "Installed package paths must be rooted.", + "The installed package folder cannot contain a reparse point.", + logger); + EnsureDirectoryPathHasNoReparsePoints( + backupPath, + "Package backup paths must be rooted.", + "The package backup folder cannot contain a reparse point.", + logger); + EnsureBackupParentExists(backupPath); + EnsureDirectoryPathHasNoReparsePoints( + backupPath, + "Package backup paths must be rooted.", + "The package backup folder cannot contain a reparse point.", + logger); + ReconcilePreviousReplacement(installedPath, backupPath, logger, deleteBackup); + EnsureDirectoryPathHasNoReparsePoints( + temporaryPath, + "Temporary package paths must be rooted.", + "The temporary package folder cannot contain a reparse point.", + logger); + + if (!Directory.Exists(temporaryFolderPath)) + { + throw new DirectoryNotFoundException( + $"Temporary package folder '{temporaryFolderPath}' was not found."); + } + + bool backupCreated = false; + try + { + EnsureDirectoryPathHasNoReparsePoints( + temporaryPath, + "Temporary package paths must be rooted.", + "The temporary package folder cannot contain a reparse point.", + logger); + EnsureDirectoryPathHasNoReparsePoints( + installedPath, + "Installed package paths must be rooted.", + "The installed package folder cannot contain a reparse point.", + logger); + EnsureDirectoryPathHasNoReparsePoints( + backupPath, + "Package backup paths must be rooted.", + "The package backup folder cannot contain a reparse point.", + logger); + if (Directory.Exists(installedFolderPath)) + { + logger.LogInformation( + "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 must be rooted.", + "The temporary package folder cannot contain a reparse point.", + logger); + logger.LogInformation( + "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.FullPath, + backupCreated, + logger); + throw; + } + + if (!backupCreated) + { + return; + } + + try + { + deleteBackup(backupPath); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + logger.LogWarning( + exception, + "Package replacement committed, but obsolete backup cleanup failed for {InstalledFolderName}. The 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); + 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."); + } + } + + /// + /// 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 the durable backup parent only after verifying its owned path chain is not linked. + /// + private static void EnsureBackupParentExists(OwnedContentPath backupPath) + { + string parentDirectory = Path.GetDirectoryName(backupPath.FullPath) + ?? throw new InvalidDataException("Package backup paths must have a parent directory."); + FileSystemPathSafety.ResolveOwnedSubpath( + backupPath.OwnerRoot, + parentDirectory, + "Package backup paths must stay inside launcher-owned recovery state.", + "The package backup folder cannot contain a reparse point."); + Directory.CreateDirectory(parentDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + parentDirectory, + "Package backup paths must be rooted.", + "The package backup folder cannot contain a reparse point."); + } + + /// + /// Verifies that an install or staging directory path does not cross or contain links before replacement. + /// + private static void EnsureDirectoryPathHasNoReparsePoints( + OwnedContentPath directoryPath, + string unrootedPathMessage, + string linkedPathMessage, + ILogger logger) + { + try + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + directoryPath.FullPath, + unrootedPathMessage, + linkedPathMessage); + if (Directory.Exists(directoryPath.FullPath)) + { + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + directoryPath.FullPath, + linkedPathMessage); + } + } + catch (InvalidDataException ex) + { + logger.LogWarning( + ex, + "Blocked package folder replacement because {FolderName} contains a reparse point.", + Path.GetFileName(directoryPath.FullPath)); + throw new IOException(linkedPathMessage, ex); + } + } + + /// + /// Attempts to restore the prior installed folder after a staged replacement failure. + /// + private static void RollBackReplacement( + string temporaryPath, + string installedPath, + string backupPath, + bool backupCreated, + ILogger logger) + { + if (!backupCreated || !Directory.Exists(backupPath) || Directory.Exists(installedPath)) + { + return; + } + + try + { + logger.LogWarning( + "Rolling back package folder replacement for {InstalledFolderName}.", + Path.GetFileName(installedPath)); + Directory.Move(backupPath, installedPath); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Failed to roll back package folder replacement for {InstalledFolderName}. Temporary folder exists: {TemporaryFolderExists}", + Path.GetFileName(installedPath), + Directory.Exists(temporaryPath)); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/PackageProgressTracker.cs b/GenLauncherGO.Infrastructure/Updating/Support/PackageProgressTracker.cs new file mode 100644 index 00000000..4297341e --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/PackageProgressTracker.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +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 object _progressGate = new(); + private readonly long _startTimestamp; + private readonly TimeProvider _timeProvider; + + private TimeSpan _lastReportElapsed; + private long _lastReportedBytesRead; + private double? _lastReportedPercentage; + private long? _totalBytes; + private long _totalBytesRead; + + public PackageProgressTracker(long? totalBytes, TimeProvider? timeProvider = null) + { + _totalBytes = totalBytes; + _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) + { + long previousBytesRead = _itemProgressBytes.TryGetValue(itemName, out long previous) + ? previous + : 0; + long normalizedBytesRead = Math.Max(previousBytesRead, Math.Max(0, bytesRead)); + _itemProgressBytes[itemName] = normalizedBytesRead; + _totalBytesRead += normalizedBytesRead - previousBytesRead; + } + } + + public PackageUpdateProgress? Update( + string itemName, + long bytesRead, + bool forceReport = false) + { + lock (_progressGate) + { + long previousBytesRead = _itemProgressBytes.TryGetValue(itemName, out long previous) + ? previous + : 0; + long normalizedBytesRead = Math.Max(previousBytesRead, Math.Max(0, bytesRead)); + _itemProgressBytes[itemName] = normalizedBytesRead; + _totalBytesRead += normalizedBytesRead - previousBytesRead; + + 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; + if (elapsed.TotalSeconds > 0.25 && _totalBytesRead > 0) + { + speedBytesPerSecond = _totalBytesRead / elapsed.TotalSeconds; + if (totalBytes.HasValue && speedBytesPerSecond > 0) + { + long remainingBytes = Math.Max(0, totalBytes.Value - _totalBytesRead); + estimatedTimeRemaining = TimeSpan.FromSeconds(remainingBytes / speedBytesPerSecond.Value); + } + } + + return new PackageUpdateProgress( + totalBytes, + _totalBytesRead, + progressPercentage, + null, + speedBytesPerSecond, + estimatedTimeRemaining); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/PackageStagingFolderCleaner.cs b/GenLauncherGO.Infrastructure/Updating/Support/PackageStagingFolderCleaner.cs new file mode 100644 index 00000000..4a73c36b --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/PackageStagingFolderCleaner.cs @@ -0,0 +1,172 @@ +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.LogInformation( + "Cleared package staging folder {StagingFolderName}.", + Path.GetFileName(stagingRoot)); + } + + /// + /// Deletes empty package staging parent folders after a staged package version folder has been moved into place. + /// + 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.LogInformation( + "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.LogInformation( + "Deleted stale staged package file {FileName}.", + Path.GetFileName(fullPath)); + } + + foreach (string directoryPath in Directory + .EnumerateDirectories(stagingRoot, "*", FileSystemPathSafety.CreateRecursiveNoLinksOptions()) + .OrderByDescending(path => path.Length) + .ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!Directory.EnumerateFileSystemEntries(directoryPath).Any()) + { + Directory.Delete(directoryPath); + } + } + } + + private static HashSet BuildExpectedInstalledPaths( + string stagingRoot, + IReadOnlyList files) + { + HashSet expectedPaths = new(StringComparer.OrdinalIgnoreCase); + foreach (RemoteFileManifestEntry file in files) + { + string destinationPath = ManifestPathResolver.ResolvePath(stagingRoot, file.FileName); + destinationPath = BigFileVariantPath.GetInstalledPath(destinationPath); + 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..979cdb0a --- /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..8eb38963 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/S3HashValidationPolicy.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.IO; +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 +{ + /// + /// 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..611e4ca3 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/S3ReusablePackageFileCopier.cs @@ -0,0 +1,193 @@ +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 must remain below launcher-owned content.", + "Reusable package paths must not contain reparse points."); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + sourcePath.FullPath, + "Reusable package paths must not contain reparse points."); + FileSystemPathSafety.ResolveOwnedSubpath( + destinationPath.OwnerRoot, + destinationPath.FullPath, + "Package staging paths must remain below launcher-owned temporary storage.", + "Package staging paths must not contain reparse points."); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + destinationPath.FullPath, + "Package staging paths must not contain reparse points."); + + 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 = BigFileVariantPath.GetInstalledPath(normalizedPath); + 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 must be rooted.", + "Reusable package paths must not contain reparse points."); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationDir, + "Package staging paths must be rooted.", + "Package staging paths must not contain reparse points."); + 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); + ulong fileSize = (ulong)file.Length; + string relativeFilePath = ManifestPathResolver.NormalizeForManifestIndex( + Path.Combine(pathAddition, file.Name)); + + if (File.Exists(targetFilePath) || + !repositoryFileIndex.TryGetValue(relativeFilePath, out RemoteFileManifestEntry? repositoryFile) || + repositoryFile.Size != fileSize || + !S3HashValidationPolicy.IsReliableMd5Hash(repositoryFile.Hash)) + { + return; + } + + string hash = await _fileHashService + .ComputeMd5HashAsync(file.FullName, cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(hash, repositoryFile.Hash, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(targetFilePath) ?? destinationDir); + await using FileStream sourceStream = file.OpenRead(); + await using FileStream destinationStream = new( + targetFilePath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.Read, + 1024 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await sourceStream.CopyToAsync(destinationStream, cancellationToken).ConfigureAwait(false); + } +} diff --git a/GenLauncherGO.Tests/AGENTS.md b/GenLauncherGO.Tests/AGENTS.md new file mode 100644 index 00000000..21acd544 --- /dev/null +++ b/GenLauncherGO.Tests/AGENTS.md @@ -0,0 +1,11 @@ +# GenLauncherGO.Tests Guidance + +- Use xUnit, FluentAssertions, and NSubstitute. Prefer a small handwritten fake 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. +- Keep hard-link/copy behavior distinct from symbolic-link and unsafe-reparse rejection. Local capability detection may skip when necessary; the complete Windows CI run must fail closed. +- 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. diff --git a/GenLauncherGO.Tests/Core/IO/LexicalPathTests.cs b/GenLauncherGO.Tests/Core/IO/LexicalPathTests.cs new file mode 100644 index 00000000..6e800120 --- /dev/null +++ b/GenLauncherGO.Tests/Core/IO/LexicalPathTests.cs @@ -0,0 +1,63 @@ +using System.IO; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Core.IO; + +public sealed class LexicalPathTests +{ + [Fact] + public void ContainmentAcceptsRootAndChildWithWindowsCaseSemantics() + { + 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 ContainmentRejectsSiblingWithMatchingPrefix() + { + using TestDirectory directory = new(); + string siblingPath = directory.Path + "-sibling"; + + bool result = LexicalPath.IsPathInDirectory(siblingPath, directory.Path); + + result.Should().BeFalse(); + } + + [Fact] + public void RelativePathsUseCanonicalSeparatorsAndDistinguishParentSegmentsFromNames() + { + 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 ResolvePathNormalizesTraversalWithoutClaimingContainment() + { + 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 NormalizeRelativePathUsesSlashSeparatorsWithoutOuterSlashes() + { + string result = LexicalPath.NormalizeRelativePath(@"\Data\INI\GameData.ini/"); + + result.Should().Be("Data/INI/GameData.ini"); + } +} diff --git a/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityReportTests.cs b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityReportTests.cs new file mode 100644 index 00000000..eb35f46f --- /dev/null +++ b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityReportTests.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Tests.Core.Integrity.Models; + +public sealed class ContentIntegrityReportTests +{ + [Fact] + public void ConstructorDefensivelyCopiesIssues() + { + List issues = new() + { + 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 IssueFlagsReflectActionableIssueKinds() + { + 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(); + } + +} diff --git a/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityTargetTests.cs b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityTargetTests.cs new file mode 100644 index 00000000..041dfe7d --- /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 ConstructorDefensivelyCopiesIgnoredPaths() + { + 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 ConstructorCanonicalizesIgnoredPathsWithCaseInsensitiveWindowsSemantics() + { + 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/Launching/LauncherGameArgumentServiceTests.cs b/GenLauncherGO.Tests/Core/Launching/LauncherGameArgumentServiceTests.cs new file mode 100644 index 00000000..858f0b64 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Launching/LauncherGameArgumentServiceTests.cs @@ -0,0 +1,61 @@ +using GenLauncherGO.Core.Launching; + +namespace GenLauncherGO.Tests.Core.Launching; + +public sealed class LauncherGameArgumentServiceTests +{ + [Fact] + public void SetArgumentEnabledAddsArgumentWhenMissing() + { + string arguments = "-foo"; + + string result = LauncherGameArgumentService.SetArgumentEnabled( + arguments, + LauncherGameArgumentService.WindowedArgument, + enabled: true); + + result.Should().Be("-foo -win"); + } + + [Fact] + public void SetArgumentEnabledDoesNotDuplicateExistingArgument() + { + string arguments = "-foo -WIN"; + + string result = LauncherGameArgumentService.SetArgumentEnabled( + arguments, + LauncherGameArgumentService.WindowedArgument, + enabled: true); + + result.Should().Be("-foo -WIN"); + } + + [Fact] + public void SetArgumentEnabledRemovesStandaloneArgumentAndKeepsOtherArguments() + { + string arguments = "-foo \"bar baz\" -win -quickstart"; + + string result = LauncherGameArgumentService.SetArgumentEnabled( + arguments, + LauncherGameArgumentService.WindowedArgument, + enabled: false); + + result.Should().Be("-foo \"bar baz\" -quickstart"); + } + + [Fact] + public void ContainsArgumentRequiresStandaloneArgument() + { + string arguments = "-windowed -quickstart"; + + bool containsWindowed = LauncherGameArgumentService.ContainsArgument( + arguments, + LauncherGameArgumentService.WindowedArgument); + bool containsQuickStart = LauncherGameArgumentService.ContainsArgument( + arguments, + LauncherGameArgumentService.QuickStartArgument); + + containsWindowed.Should().BeFalse(); + containsQuickStart.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..e03780cb --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentKeyTests.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class LauncherContentKeyTests +{ + [Fact] + public void VersionIdentityDeduplicatesCaseInsensitiveMatchesInHashCollections() + { + LauncherContentVersion first = CreateVersion("ShockWave", "1.2", ModificationType.Addon, "Parent"); + LauncherContentVersion duplicate = CreateVersion("shockwave", "1.2", ModificationType.Addon, "parent"); + var keys = new HashSet(); + + keys.Add(first.ContentKey); + keys.Add(duplicate.ContentKey); + + keys.Should().ContainSingle().Which.Should().Be(first.ContentKey); + } + + [Fact] + public void VersionIdentityIncludesVersionTypeAndParent() + { + LauncherContentKey key = + CreateVersion("Shared", "1.0", ModificationType.Addon, "First").ContentKey; + LauncherContentKey otherVersion = + CreateVersion("Shared", "2.0", ModificationType.Addon, "First").ContentKey; + LauncherContentKey otherType = + CreateVersion("Shared", "1.0", ModificationType.Patch, "First").ContentKey; + LauncherContentKey otherParent = + CreateVersion("Shared", "1.0", ModificationType.Addon, "Second").ContentKey; + + key.Should().NotBe(otherVersion); + key.Should().NotBe(otherType); + key.Should().NotBe(otherParent); + } + + [Fact] + public void MissingIdentityTextRetainsEmptyStringComparisonSemantics() + { + 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(); + } + + [Fact] + public void OriginalGameIdentityIsStableAndMatchesLegacyCasing() + { + 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 StableStringPreservesExistingIntegrityIdentityFormat() + { + var key = new LauncherContentKey( + ModificationType.Addon, + "ShockWave Patch", + "Music Pack", + "V1.2"); + + key.ToStableString().Should().Be("addon:shockwave patch:music pack:v1.2"); + } + + private static LauncherContentVersion CreateVersion( + string name, + string version, + ModificationType type, + string parentContentName) + { + return new LauncherContentVersion + { + Name = name, + Version = version, + ModificationType = type, + ParentContentName = parentContentName + }; + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentTests.cs new file mode 100644 index 00000000..d245634c --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentTests.cs @@ -0,0 +1,136 @@ +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class LauncherContentTests +{ + [Fact] + public void AddOrUpdateKeepsOneCanonicalVersionAndCombinesLocalStateWithRemoteMetadata() + { + var localState = new LauncherContentInstallation + { + Installed = true, + ContentSourceKind = ContentSourceKind.UnknownLegacy + }; + var localVersion = new LauncherContentVersion(localState) + { + Name = "ShockWave", + Version = "1.2", + ModificationType = ModificationType.Mod + }; + 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" + }; + LauncherContent content = CreateContent(localVersion, remoteVersion); + + LauncherContentVersion merged = content.Versions.Should().ContainSingle().Which; + merged.ContentKey.Should().Be(localVersion.ContentKey); + merged.SimpleDownloadLink.Should().Be(remoteVersion.SimpleDownloadLink); + merged.ModDBLink.Should().Be(remoteVersion.ModDBLink); + 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 LatestVersionIsTheCardPresentationMetadataAuthority() + { + LauncherContentVersion latest = CreateVersion("2.0", supportLink: "https://example.test/current"); + LauncherContent content = CreateContent( + CreateVersion("1.0", supportLink: "https://example.test/old"), + latest); + + content.LatestVersion.Should().BeSameAs(latest); + content.LatestVersion.SupportLink.Should().Be("https://example.test/current"); + } + + [Fact] + public void SelectedVersionUsesPersistedInstalledSelectionBeforeFallbacks() + { + LauncherContentVersion earliestInstalled = CreateVersion("1.0", installed: true); + LauncherContentVersion selectedInstalled = CreateVersion("2.0", installed: true, isSelected: true); + LauncherContentVersion latestRemote = CreateVersion("3.0"); + LauncherContent content = CreateContent( + earliestInstalled, + latestRemote, + selectedInstalled); + + LauncherContentVersion? selectedVersion = content.GetSelectedVersion(); + + selectedVersion.Should().BeSameAs(selectedInstalled); + } + + [Fact] + public void SelectedVersionFallsBackToEarliestInstalledThenEarliestKnownVersion() + { + LauncherContentVersion latestRemote = CreateVersion("3.0"); + LauncherContentVersion earliestInstalled = CreateVersion("1.0", installed: true); + LauncherContentVersion middleRemote = CreateVersion("2.0"); + LauncherContent installedContent = CreateContent( + latestRemote, + earliestInstalled, + middleRemote); + LauncherContent remoteContent = CreateContent(latestRemote, middleRemote); + + installedContent.GetSelectedVersion().Should().BeSameAs(earliestInstalled); + remoteContent.GetSelectedVersion().Should().BeSameAs(middleRemote); + } + + [Fact] + public void LatestInstalledVersionUsesCanonicalVersionOrdering() + { + LauncherContentVersion latestInstalled = CreateVersion("2.0", installed: true); + LauncherContentVersion earliestInstalled = CreateVersion("1.0", installed: true); + LauncherContentVersion remoteUpdate = CreateVersion("3.0"); + LauncherContent content = CreateContent( + latestInstalled, + remoteUpdate, + earliestInstalled); + + content.LatestInstalledVersion.Should().BeSameAs(latestInstalled); + } + + private static LauncherContent CreateContent(params LauncherContentVersion[] versions) + { + var data = new LauncherData(); + foreach (LauncherContentVersion version in versions) + { + data.AddOrUpdate(version); + } + + return data.FindContent(versions[0].ContentKey)!; + } + + private static LauncherContentVersion CreateVersion( + string version, + string supportLink = "", + bool installed = false, + bool isSelected = false) + { + return new LauncherContentVersion(new LauncherContentInstallation + { + Installed = installed, + IsSelected = isSelected, + }) + { + Name = "ShockWave", + Version = version, + ModificationType = ModificationType.Mod, + SupportLink = supportLink + }; + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentVersionTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentVersionTests.cs new file mode 100644 index 00000000..b31ba7d7 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentVersionTests.cs @@ -0,0 +1,64 @@ +using System; +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 VersionComparerPreservesLegacyNumericProjection( + 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); + } + + [Fact] + public void CompareToHandlesVeryLargeDigitSequencesWithoutOverflow() + { + 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 ResolveContentSourceKindUsesPackageMetadataPrecedence( + 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..2209053f --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/LauncherDataTests.cs @@ -0,0 +1,283 @@ +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 AddOrUpdateAddsContentToMatchingCollections() + { + LauncherData launcherData = new(); + + launcherData.AddOrUpdate(CreateVersion("Shockwave", ModificationType.Mod)); + launcherData.AddOrUpdate(CreateVersion("Patch", ModificationType.Patch)); + launcherData.AddOrUpdate(CreateVersion("Addon", ModificationType.Addon, parentContentName: "Shockwave")); + launcherData.AddOrUpdate(CreateVersion("Orphan Addon", 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 AddOrUpdateMergesMatchingVersionsIntoExistingContentCard() + { + LauncherData launcherData = new(); + LauncherContentVersion installedVersion = CreateVersion("Shockwave", ModificationType.Mod, "1.0"); + installedVersion.Installation.Installed = true; + LauncherContentVersion selectedVersion = CreateVersion("shockwave", ModificationType.Mod, "1.0"); + selectedVersion.Installation.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 DeleteRemovesMatchingVersionAndDeletesEmptyContentCard() + { + LauncherData launcherData = new(); + LauncherContentVersion versionOne = CreateVersion("Shockwave", ModificationType.Mod, "1.0"); + LauncherContentVersion versionTwo = CreateVersion("Shockwave", ModificationType.Mod, "2.0"); + launcherData.AddOrUpdate(versionOne); + launcherData.AddOrUpdate(versionTwo); + + launcherData.DeleteVersion(versionOne.ContentKey); + launcherData.DeleteVersion(versionTwo.ContentKey); + + launcherData.Modifications.Should().BeEmpty(); + } + + [Fact] + public void AddOrUpdateKeepsChildCardsWithSameNameUnderDifferentParentsSeparate() + { + LauncherData launcherData = new(); + LauncherContentVersion firstAddon = CreateVersion("Shared Addon", ModificationType.Addon, parentContentName: "First"); + LauncherContentVersion secondAddon = CreateVersion("Shared Addon", 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 FindContentUsesTypeParentNameAndOmitsVersionForCardLookup() + { + LauncherData launcherData = new(); + LauncherContentVersion firstAddon = CreateVersion( + "Shared Addon", + ModificationType.Addon, + "1.0", + "First"); + LauncherContentVersion secondAddon = CreateVersion( + "Shared Addon", + ModificationType.Addon, + "2.0", + "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 DeleteContentRemovesEveryVersionAndDependentAddonAndPatchCards() + { + LauncherData launcherData = new(); + LauncherContentVersion mod = CreateVersion("Parent", ModificationType.Mod); + LauncherContentVersion secondModVersion = CreateVersion("Parent", ModificationType.Mod, "2.0"); + LauncherContentVersion addon = CreateVersion("Addon", ModificationType.Addon, parentContentName: "Parent"); + LauncherContentVersion patch = CreateVersion("Patch", ModificationType.Patch, parentContentName: "Parent"); + LauncherContentVersion patchAddon = CreateVersion("Patch Addon", ModificationType.Addon, parentContentName: "Patch"); + LauncherContentVersion unrelatedAddon = CreateVersion("Addon", 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 DeleteAddonRemovesOnlyMatchingAddonCard() + { + LauncherData launcherData = new(); + LauncherContentVersion addon = CreateVersion("Addon", ModificationType.Addon, parentContentName: "Shockwave"); + LauncherContentVersion patch = CreateVersion("Addon", ModificationType.Patch); + launcherData.AddOrUpdate(addon); + launcherData.AddOrUpdate(patch); + + launcherData.DeleteVersion(addon.ContentKey); + + launcherData.Addons.Should().BeEmpty(); + launcherData.Patches.Should().ContainSingle(); + } + + [Fact] + public void DeletePatchRemovesOnlyMatchingPatchCard() + { + LauncherData launcherData = new(); + LauncherContentVersion addon = CreateVersion("Patch", ModificationType.Addon, parentContentName: "Shockwave"); + LauncherContentVersion patch = CreateVersion("Patch", ModificationType.Patch); + launcherData.AddOrUpdate(addon); + launcherData.AddOrUpdate(patch); + + launcherData.DeleteVersion(patch.ContentKey); + + launcherData.Patches.Should().BeEmpty(); + launcherData.Addons.Should().ContainSingle(); + } + + [Fact] + public void DeletePatchAlsoDeletesDependentAddonCards() + { + LauncherData launcherData = new(); + LauncherContentVersion patch = CreateVersion("Patch", ModificationType.Patch, parentContentName: "Shockwave"); + LauncherContentVersion dependentAddon = CreateVersion("Addon", ModificationType.Addon, parentContentName: "Patch"); + LauncherContentVersion unrelatedAddon = CreateVersion("Addon", 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"); + } + + [Fact] + public void AddOrUpdateDoesNotEmbedAdvertisingInPersistentContentCollections() + { + LauncherData launcherData = new(); + LauncherContentVersion advertising = CreateVersion("Featured", ModificationType.Advertising); + launcherData.AddOrUpdate(advertising); + + launcherData.Modifications.Should().BeEmpty(); + launcherData.FindContent(advertising.ContentKey).Should().BeNull(); + } + + [Fact] + public void PersistedSelectedModificationQueryReturnsSelectedCard() + { + LauncherData launcherData = new(); + LauncherContentVersion selectedVersion = CreateVersion("ShockWave", ModificationType.Mod, "1.0"); + selectedVersion.Installation.IsSelected = true; + launcherData.AddOrUpdate(selectedVersion); + launcherData.AddOrUpdate(CreateVersion("ShockWave", ModificationType.Mod, "1.1")); + + LauncherContent? selectedModification = launcherData.GetSelectedMod(); + + selectedModification.Should().NotBeNull(); + selectedModification!.Name.Should().Be("ShockWave"); + } + + [Fact] + public void OriginalGameContentQueriesUseOriginalGameDependenciesWhenNoParentIsSupplied() + { + LauncherData launcherData = new(); + LauncherContentVersion patch = CreateVersion( + "Original Patch", + ModificationType.Patch, + parentContentName: LauncherContentKey.OriginalGame.Name); + patch.Installation.IsSelected = true; + LauncherContentVersion originalAddon = CreateVersion( + "Original Addon", + ModificationType.Addon, + "2.0", + LauncherContentKey.OriginalGame.Name); + originalAddon.Installation.IsSelected = true; + LauncherContentVersion patchAddon = CreateVersion( + "Patch Addon", + ModificationType.Addon, + "3.0", + "Original Patch"); + patchAddon.Installation.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().BeEquivalentTo("Original Addon", "Patch Addon"); + } + + [Fact] + public void GetAddonsForIncludesPatchDependentAddons() + { + LauncherData launcherData = new(); + LauncherContentVersion modification = CreateVersion("ShockWave", ModificationType.Mod); + modification.Installation.IsSelected = true; + LauncherContentVersion patch = CreateVersion( + "ShockWave Patch", + ModificationType.Patch, + "1.1", + "ShockWave"); + patch.Installation.IsSelected = true; + LauncherContentVersion patchAddon = CreateVersion( + "Patch Addon", + ModificationType.Addon, + "2.0", + "ShockWave Patch"); + patchAddon.Installation.IsSelected = true; + launcherData.AddOrUpdate(modification); + launcherData.AddOrUpdate(patch); + launcherData.AddOrUpdate(patchAddon); + launcherData.AddOrUpdate(CreateVersion( + "Mod Addon", + ModificationType.Addon, + "3.0", + "ShockWave")); + + LauncherContent selectedModification = launcherData.Modifications.Single(); + LauncherContent selectedPatch = launcherData.Patches.Single(); + IReadOnlyList addons = launcherData.GetAddonsFor( + selectedModification, + selectedPatch); + + addons.Select(addon => addon.Name).Should().BeEquivalentTo("Patch Addon", "Mod Addon"); + } + + private static LauncherContentVersion CreateVersion( + string name, + ModificationType modificationType, + string version = "1.0", + string parentContentName = "") + { + return new LauncherContentVersion + { + Name = name, + Version = version, + ModificationType = modificationType, + ParentContentName = parentContentName + }; + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/OwnedContentPathTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/OwnedContentPathTests.cs new file mode 100644 index 00000000..d6938daf --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/OwnedContentPathTests.cs @@ -0,0 +1,36 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class OwnedContentPathTests +{ + [Fact] + public void ConstructorNormalizesOwnedChildAndExposesRelativePath() + { + string ownerRoot = Path.GetFullPath(Path.Combine("GenLauncherGO.Tests", "Mods")); + string fullPath = Path.Combine(ownerRoot, "ShockWave", "..", "ShockWave", "1.2"); + + var result = new OwnedContentPath(ownerRoot, fullPath); + + result.OwnerRoot.Should().Be(Path.TrimEndingDirectorySeparator(Path.GetFullPath(ownerRoot))); + result.FullPath.Should().Be(Path.GetFullPath(Path.Combine(ownerRoot, "ShockWave", "1.2"))); + result.RelativePath.Should().Be("ShockWave/1.2"); + } + + [Theory] + [InlineData("same")] + [InlineData("outside")] + public void ConstructorRejectsPathOutsideOwnershipBoundary(string scenario) + { + string ownerRoot = Path.GetFullPath(Path.Combine("GenLauncherGO.Tests", "OwnershipBoundary")); + string fullPath = scenario == "same" + ? ownerRoot + : Path.Combine(ownerRoot, "..", "Outside"); + + Action act = () => new OwnedContentPath(ownerRoot, fullPath); + + act.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs b/GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs new file mode 100644 index 00000000..7fb3292b --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs @@ -0,0 +1,195 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Mods.Services; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Core.Mods.Services; + +public sealed class LauncherContentPathResolverTests +{ + [Fact] + public void ResolveVersionPath_WhenIdentityIsMod_ReturnsModVersionDirectory() + { + LauncherPaths paths = CreatePaths(); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "Rise Of Reds", + Version = "1.9" + }; + + OwnedContentPath? result = LauncherContentPathResolver.ResolveVersionPath(paths, version.ContentKey); + + result!.FullPath.Should().Be(Path.Combine(paths.ModsDirectory, "Rise Of Reds", "1.9")); + } + + [Fact] + public void ResolveVersionPath_WhenIdentityIsAddon_ReturnsAddonVersionDirectory() + { + LauncherPaths paths = CreatePaths(); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Addon, + ParentContentName = "Rise Of Reds", + Name = "Music Pack", + Version = "2.0" + }; + + OwnedContentPath? result = LauncherContentPathResolver.ResolveVersionPath(paths, version.ContentKey); + + result!.FullPath.Should().Be(Path.Combine( + paths.ModsDirectory, + "Rise Of Reds", + LauncherFileSystemLayout.AddonsFolderName, + "Music Pack", + "2.0")); + } + + [Fact] + public void ResolveVersionPath_WhenIdentityIsPatch_ReturnsPatchVersionDirectory() + { + LauncherPaths paths = CreatePaths(); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Patch, + ParentContentName = "Rise Of Reds", + Name = "Hotfix", + Version = "2.1" + }; + + OwnedContentPath? result = LauncherContentPathResolver.ResolveVersionPath(paths, version.ContentKey); + + result!.FullPath.Should().Be(Path.Combine( + paths.ModsDirectory, + "Rise Of Reds", + LauncherFileSystemLayout.PatchesFolderName, + "Hotfix", + "2.1")); + } + + [Fact] + public void ResolveVersionPath_WhenIdentityTypeIsUnsupported_ReturnsNull() + { + LauncherPaths paths = CreatePaths(); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Advertising, + Name = "News", + Version = "1" + }; + + OwnedContentPath? result = LauncherContentPathResolver.ResolveVersionPath(paths, version.ContentKey); + + result.Should().BeNull(); + } + + [Fact] + public void ResolveVersionPath_WhenModNameContainsPathTraversal_Throws() + { + LauncherPaths paths = CreatePaths(); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = $"..{Path.DirectorySeparatorChar}Escape", + Version = "1.0" + }; + + Action act = () => LauncherContentPathResolver.ResolveVersionPath(paths, version.ContentKey); + + act.Should().Throw(); + } + + [Fact] + public void ResolveVersionPath_WhenAddonDependenceContainsPathTraversal_Throws() + { + LauncherPaths paths = CreatePaths(); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Addon, + ParentContentName = $"..{Path.DirectorySeparatorChar}Escape", + Name = "Music Pack", + Version = "1.0" + }; + + Action act = () => LauncherContentPathResolver.ResolveVersionPath(paths, version.ContentKey); + + act.Should().Throw(); + } + + [Fact] + public void ResolveVersionPath_WhenPatchVersionContainsPathTraversal_Throws() + { + LauncherPaths paths = CreatePaths(); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Patch, + ParentContentName = "Rise Of Reds", + Name = "Hotfix", + Version = $"..{Path.DirectorySeparatorChar}Escape" + }; + + Action act = () => LauncherContentPathResolver.ResolveVersionPath(paths, version.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(); + var version = new LauncherContentVersion + { + ModificationType = modificationType, + Name = name, + Version = versionName, + ParentContentName = parentContentName + }; + + OwnedContentPath? result = LauncherContentPathResolver.ResolveVersionPath(paths, version.ContentKey); + + result.Should().BeNull(); + } + + [Theory] + [InlineData(ModificationType.Mod, null, "ShockWave", "1.2")] + [InlineData(ModificationType.Addon, "ShockWave", "Music", "2.0")] + [InlineData(ModificationType.Patch, "ShockWave", "Hotfix", "3.0")] + public void ResolveVersionPathReturnsOwnedPathForDomainIdentity( + ModificationType modificationType, + string? parentContentName, + string name, + string versionName) + { + LauncherPaths paths = CreatePaths(); + var catalogVersion = new LauncherContentVersion + { + ModificationType = modificationType, + ParentContentName = parentContentName ?? string.Empty, + Name = name, + Version = versionName, + }; + OwnedContentPath? catalogPath = LauncherContentPathResolver.ResolveVersionPath( + paths, + catalogVersion.ContentKey); + + catalogPath.Should().NotBeNull(); + catalogPath!.OwnerRoot.Should().Be(Path.GetFullPath(paths.ModsDirectory)); + } + + private static LauncherPaths CreatePaths() + { + string root = Path.GetFullPath("GenLauncherGO.Tests"); + return TestLauncherPaths.Create(Path.Combine(root, "Game")); + } + +} diff --git a/GenLauncherGO.Tests/Core/Settings/Models/LauncherPreferencesTests.cs b/GenLauncherGO.Tests/Core/Settings/Models/LauncherPreferencesTests.cs new file mode 100644 index 00000000..6a67d2de --- /dev/null +++ b/GenLauncherGO.Tests/Core/Settings/Models/LauncherPreferencesTests.cs @@ -0,0 +1,68 @@ +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, @"C:\Games\Generals")] + [InlineData(SupportedGame.ZeroHour, @"D:\Games\ZeroHour")] + public void InstallationsGetAndWithPathUseSupportedGameIdentity(SupportedGame game, string path) + { + LauncherInstallations installations = new LauncherInstallations().WithPath(game, path); + + installations.GetPath(game).Should().Be(path); + } + + [Fact] + public void InstallationsResolveConfiguredPreferredGameWithSingleInstallationFallback() + { + 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)] + [InlineData(SupportedGame.ZeroHour)] + public void GamePreferencesGetAndWithUseSupportedGameIdentity(SupportedGame game) + { + var preferences = new LauncherGamePreferences { GameArguments = "-quickstart" }; + + LauncherGamePreferencesSet games = new LauncherGamePreferencesSet().With(game, preferences); + + games.Get(game).Should().Be(preferences); + } + + [Fact] + public void CustomExecutableNormalizesNamesAndRequiresRootLevelExeFile() + { + LauncherCustomExecutable executable = new(" My Client ", " custom.exe "); + + executable.DisplayName.Should().Be("My Client"); + executable.ExecutableName.Should().Be("custom.exe"); + + Action nested = () => new LauncherCustomExecutable("Nested", @"tools\custom.exe"); + Action traversal = () => new LauncherCustomExecutable("Traversal", @"..\custom.exe"); + Action nonExecutable = () => new LauncherCustomExecutable("Text", "custom.txt"); + Action blankName = () => new LauncherCustomExecutable(" ", "custom.exe"); + + nested.Should().Throw(); + traversal.Should().Throw(); + nonExecutable.Should().Throw(); + blankName.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Core/Startup/GameInstallationServiceExtensionsTests.cs b/GenLauncherGO.Tests/Core/Startup/GameInstallationServiceExtensionsTests.cs new file mode 100644 index 00000000..4bc6c5f2 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/GameInstallationServiceExtensionsTests.cs @@ -0,0 +1,97 @@ +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_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 }); + } + + [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.RequiredFilesMissing); + }); + + 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() + { + const string canonicalPath = @"C:\Games\Shared"; + IGameInstallationService service = CreateService((_, _) => + GameInstallationValidationResult.Valid(canonicalPath)); + + 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/LauncherPathsTests.cs b/GenLauncherGO.Tests/Core/Startup/LauncherPathsTests.cs new file mode 100644 index 00000000..4fd30fef --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/LauncherPathsTests.cs @@ -0,0 +1,147 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Core.Startup; + +public sealed class LauncherPathsTests +{ + [Fact] + public void ConstructorNormalizesRootsAndDerivesCanonicalLayout() + { + 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")); + } + + [Fact] + public void GetPackageTemporaryPathBuildsOwnedPathUnderTempDirectory() + { + LauncherPaths paths = CreatePaths(); + 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 GetPackageTemporaryPathUsesFolderNameWhenInstallIsOutsideModsDirectory() + { + LauncherPaths paths = CreatePaths(); + 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 GetPackageTemporaryPathPreservesModsChildNamesThatStartWithDots() + { + LauncherPaths paths = CreatePaths(); + 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 GetPackageBackupPathMirrorsModsRelativePathUnderDurableStateDirectory() + { + LauncherPaths paths = CreatePaths(); + string installedFolderPath = Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"); + + OwnedContentPath backupPath = paths.GetPackageBackupPath( + new OwnedContentPath(paths.ModsDirectory, installedFolderPath)); + + string backupRoot = Path.Combine(paths.StateDirectory, "PackageBackups"); + backupPath.OwnerRoot.Should().Be(backupRoot); + backupPath.FullPath.Should().Be(Path.Combine(backupRoot, "ShockWave", "1.2")); + } + + [Fact] + public void GetPackageBackupPathRejectsInstallOutsideModsDirectory() + { + LauncherPaths paths = CreatePaths(); + var installedPath = new OwnedContentPath( + paths.GameDirectory, + Path.Combine(paths.GameDirectory, "Data")); + + Action act = () => paths.GetPackageBackupPath(installedPath); + + act.Should().Throw() + .WithParameterName("installedPath"); + } + + [Fact] + public void LauncherDataFilePathBuildsPathUnderRuntimeStateDirectory() + { + LauncherPaths paths = CreatePaths(); + + string launcherDataFilePath = paths.LauncherDataFilePath; + + launcherDataFilePath.Should().Be( + Path.Combine(paths.RuntimeDirectory, "State", "LauncherData.yaml")); + } + + [Fact] + public void GetModificationImageFilePathBuildsPathUnderModificationImageCache() + { + LauncherPaths paths = CreatePaths(); + + string imageFilePath = paths.GetModificationImageFilePath("ShockWave", "1.2.png"); + + imageFilePath.Should().Be(Path.Combine(paths.ImagesDirectory, "ShockWave", "1.2.png")); + } + + [Fact] + public void GetModificationImagesDirectoryThrowsForPathTraversalModificationName() + { + LauncherPaths paths = CreatePaths(); + + Action act = () => paths.GetModificationImagesDirectory($"..{Path.DirectorySeparatorChar}Escape"); + + act.Should().Throw(); + } + + [Fact] + public void GetModificationImageFilePathThrowsForPathTraversalImageFileName() + { + LauncherPaths paths = CreatePaths(); + + Action act = () => paths.GetModificationImageFilePath( + "ShockWave", + $"..{Path.DirectorySeparatorChar}1.2.png"); + + act.Should().Throw(); + } + + private static LauncherPaths CreatePaths() + { + return TestLauncherPaths.Create(); + } +} diff --git a/GenLauncherGO.Tests/Core/Startup/LauncherRuntimePathContextTests.cs b/GenLauncherGO.Tests/Core/Startup/LauncherRuntimePathContextTests.cs new file mode 100644 index 00000000..6d7ba538 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/LauncherRuntimePathContextTests.cs @@ -0,0 +1,38 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Startup; + +public sealed class LauncherRuntimePathContextTests +{ + [Fact] + public void SwitchActiveAtomicallyReplacesImmutablePathSnapshot() + { + 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 ConstructorRejectsPathsOwnedByAnotherLauncherStorageRoot() + { + 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..2c9373cd --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/LauncherStoragePathsTests.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Startup; + +public sealed class LauncherStoragePathsTests +{ + [Fact] + public void ConstructorDerivesSharedStandaloneAndIsolatedGamePaths() + { + string executableDirectory = Path.GetFullPath(Path.Combine("GenLauncherGO.Tests", "Launcher")); + string gameDirectory = Path.GetFullPath(Path.Combine("GenLauncherGO.Tests", "ZeroHour")); + var storage = new LauncherStoragePaths(executableDirectory); + + LauncherPaths gamePaths = storage.CreateGamePaths(SupportedGame.ZeroHour, gameDirectory); + + storage.ExecutableDirectory.Should().Be(executableDirectory); + storage.DataDirectory.Should().Be(Path.Combine(executableDirectory, "GenLauncherGO Data")); + storage.LogsDirectory.Should().Be(Path.Combine(executableDirectory, "GenLauncherGO Data", "Logs")); + storage.PreferencesFilePath.Should().Be( + Path.Combine(executableDirectory, "GenLauncherGO Data", "LauncherPreferences.yaml")); + gamePaths.Game.Should().Be(SupportedGame.ZeroHour); + gamePaths.GameDirectory.Should().Be(gameDirectory); + gamePaths.OwnedGameDataDirectory.Should().Be( + Path.Combine(executableDirectory, "GenLauncherGO Data", "C&C Zero Hour Data")); + gamePaths.ModsDirectory.Should().Be( + Path.Combine(executableDirectory, "GenLauncherGO Data", "C&C Zero Hour Data", "Mods")); + } + + [Fact] + public void CreateGamePathsRejectsUnknownGame() + { + var storage = new LauncherStoragePaths(Path.GetFullPath("Launcher")); + + Action act = () => storage.CreateGamePaths(SupportedGame.Unknown, Path.GetFullPath("Game")); + + act.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/GenLauncherGO.Tests.csproj b/GenLauncherGO.Tests/GenLauncherGO.Tests.csproj new file mode 100644 index 00000000..043a7f8c --- /dev/null +++ b/GenLauncherGO.Tests/GenLauncherGO.Tests.csproj @@ -0,0 +1,46 @@ + + + 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 + + + diff --git a/GenLauncherGO.Tests/GlobalUsings.cs b/GenLauncherGO.Tests/GlobalUsings.cs new file mode 100644 index 00000000..3625f71e --- /dev/null +++ b/GenLauncherGO.Tests/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using FluentAssertions; +global using NSubstitute; +global using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/GenLauncherGO.Tests/Infrastructure/ArchiveExtractorTests.cs b/GenLauncherGO.Tests/Infrastructure/ArchiveExtractorTests.cs new file mode 100644 index 00000000..1bb9c4e3 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/ArchiveExtractorTests.cs @@ -0,0 +1,72 @@ +using System; +using System.IO; +using System.IO.Compression; +using GenLauncherGO.Infrastructure.Archives; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure; + +public sealed class ArchiveExtractorTests +{ + [Fact] + public void ExtractToDirectoryPreservesBigFilesByDefault() + { + 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); + + File.Exists(Path.Combine(destinationDirectory, "Data", "test.big")).Should().BeTrue(); + File.Exists(Path.Combine(destinationDirectory, "Data", "test.gib")).Should().BeFalse(); + } + + [Fact] + public void ExtractToDirectoryConvertsBigFilesWhenRequested() + { + 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, + convertBigFilesToGib: true); + + File.Exists(Path.Combine(destinationDirectory, "Data", "test.gib")).Should().BeTrue(); + File.Exists(Path.Combine(destinationDirectory, "Data", "test.big")).Should().BeFalse(); + } + + [Fact] + public void ExtractToDirectoryRejectsEntriesOutsideDestinationDirectory() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.GetPath("extract"); + string escapedFilePath = directory.GetPath("escape.txt"); + CreateZipArchive(archivePath, "../escape.txt", "escaped"); + + var extractor = new ArchiveExtractor(); + + Action act = () => extractor.ExtractToDirectory(archivePath, destinationDirectory); + + act.Should().Throw() + .WithMessage("*outside the destination folder*"); + File.Exists(escapedFilePath).Should().BeFalse(); + } + + private static void CreateZipArchive(string archivePath, string entryName, string contents) + { + using ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Create); + ZipArchiveEntry entry = archive.CreateEntry(entryName); + using Stream entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream); + writer.Write(contents); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Common/BigFileVariantPathTests.cs b/GenLauncherGO.Tests/Infrastructure/Common/BigFileVariantPathTests.cs new file mode 100644 index 00000000..795bbf71 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Common/BigFileVariantPathTests.cs @@ -0,0 +1,91 @@ +using System.IO; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Common; + +public sealed class BigFileVariantPathTests +{ + [Fact] + public void VariantMappingsRoundTripCaseInsensitivePackagePathsAndPreserveOtherFiles() + { + 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 GetExistingDownloadedPathPrefersRequestedBigPath() + { + 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 GetExistingDownloadedPathFallsBackToConvertedGibPath() + { + 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 ConvertBigFileToGibMovesBigFileAndReplacesExistingGib() + { + 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 PrepareBigFileResumePathMovesConvertedGibBackToBigPath() + { + 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(); + } + + [Fact] + public void PrepareBigFileResumePathReturnsWhenResumeMoveIsNotNeeded() + { + using TestDirectory testDirectory = new(); + string bigPath = Path.Combine(testDirectory.Path, "asset.big"); + string gibPath = Path.Combine(testDirectory.Path, "asset.gib"); + File.WriteAllText(bigPath, "existing"); + + BigFileVariantPath.PrepareBigFileResumePath(bigPath); + + File.ReadAllText(bigPath).Should().Be("existing"); + File.Exists(gibPath).Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Common/FileSystemPathSafetyTests.cs b/GenLauncherGO.Tests/Infrastructure/Common/FileSystemPathSafetyTests.cs new file mode 100644 index 00000000..15802dab --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Common/FileSystemPathSafetyTests.cs @@ -0,0 +1,99 @@ +using System; +using System.IO; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Common; + +public sealed class FileSystemPathSafetyTests +{ + [Fact] + public void ResolveOwnedSubpathReturnsNormalizedChildPath() + { + using TestDirectory directory = new(); + string candidatePath = Path.Combine(directory.Path, "Child", "..", "Child", "file.txt"); + + string result = FileSystemPathSafety.ResolveOwnedSubpath( + directory.Path, + candidatePath, + "outside", + "linked"); + + result.Should().Be(Path.GetFullPath(Path.Combine(directory.Path, "Child", "file.txt"))); + } + + [Fact] + public void ResolveOwnedSubpathRejectsPathOutsideOwnedRoot() + { + using TestDirectory directory = new(); + string outsidePath = Path.Combine(directory.Path, "..", "outside.txt"); + + Action act = () => FileSystemPathSafety.ResolveOwnedSubpath( + directory.Path, + outsidePath, + "outside root", + "linked path"); + + act.Should().Throw() + .WithMessage("outside root"); + } + + [Fact] + public void ExistingPathChainContainsReparsePointReturnsFalseForRootAndMissingChild() + { + using TestDirectory directory = new(); + string missingChild = Path.Combine(directory.Path, "Missing", "file.txt"); + + FileSystemPathSafety.ExistingPathChainContainsReparsePoint( + Path.GetPathRoot(directory.Path)!, + "unrooted").Should().BeFalse(); + FileSystemPathSafety.ExistingPathChainContainsReparsePoint( + missingChild, + "unrooted").Should().BeFalse(); + } + + [Fact] + public void EnsureExistingPathChainHasNoReparsePointsAllowsNormalFiles() + { + using TestDirectory directory = new(); + string filePath = Path.Combine(directory.Path, "file.txt"); + File.WriteAllText(filePath, "content"); + + Action act = () => FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + filePath, + "unrooted", + "linked"); + + act.Should().NotThrow(); + FileSystemPathSafety.IsReparsePoint(filePath).Should().BeFalse(); + } + + [SymbolicLinkFact] + public void EnsureDirectoryTreeHasNoReparsePointsRejectsChildReparsePoint() + { + 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); + SymbolicLinkTestSupport.CreateDirectoryLink(linkPath, linkedTarget); + + Action act = () => FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints(rootPath, "linked"); + + act.Should().Throw() + .WithMessage("linked"); + } + + [Fact] + public void CreateRecursiveNoLinksOptionsSkipsReparsePoints() + { + 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..b00532c8 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Common/ManifestPathResolverTests.cs @@ -0,0 +1,42 @@ +using System; +using System.IO; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Tests.Testing; + +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 ResolvePathReturnsFullPathUnderRoot(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"))); + } + + [Theory] + [InlineData(@"C:\Package\Data.big")] + [InlineData("C:Package/Data.big")] + [InlineData("../Data.big")] + [InlineData("./Data.big")] + public void NormalizeRelativePathRejectsUnsafePaths(string manifestFileName) + { + Action act = () => ManifestPathResolver.NormalizeRelativePath(manifestFileName); + + act.Should().Throw(); + } + + [Fact] + public void NormalizeForManifestIndexUsesSlashSeparators() + { + 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..d894e2b1 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Common/OwnedDirectoryTreeTests.cs @@ -0,0 +1,97 @@ +using System; +using System.Diagnostics; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Common; + +public sealed class OwnedDirectoryTreeTests +{ + [Fact] + public void DeleteIfExistsDeletesNestedDirectoryLinkWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = Path.Combine(directory.Path, "Owned"); + string contentPath = Path.Combine(ownedRoot, "Content"); + string targetPath = Path.Combine(directory.Path, "ExternalTarget"); + string linkPath = Path.Combine(contentPath, "Linked"); + Directory.CreateDirectory(contentPath); + Directory.CreateDirectory(targetPath); + File.WriteAllText(Path.Combine(contentPath, "owned.txt"), "owned"); + File.WriteAllText(Path.Combine(targetPath, "target.txt"), "target"); + CreateDirectoryJunction(linkPath, targetPath); + + bool deleted = OwnedDirectoryTree.DeleteIfExists( + new OwnedContentPath(ownedRoot, contentPath)); + + deleted.Should().BeTrue(); + Directory.Exists(contentPath).Should().BeFalse(); + File.ReadAllText(Path.Combine(targetPath, "target.txt")).Should().Be("target"); + } + + [Fact] + public void DeleteIfExistsDeletesLinkedLeafWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = Path.Combine(directory.Path, "Owned"); + string targetPath = Path.Combine(directory.Path, "ExternalTarget"); + string linkPath = Path.Combine(ownedRoot, "Version"); + Directory.CreateDirectory(ownedRoot); + Directory.CreateDirectory(targetPath); + File.WriteAllText(Path.Combine(targetPath, "target.txt"), "target"); + CreateDirectoryJunction(linkPath, targetPath); + + bool deleted = OwnedDirectoryTree.DeleteIfExists( + new OwnedContentPath(ownedRoot, linkPath)); + + deleted.Should().BeTrue(); + Directory.Exists(linkPath).Should().BeFalse(); + File.ReadAllText(Path.Combine(targetPath, "target.txt")).Should().Be("target"); + } + + [Fact] + public void DeleteIfExistsRejectsLinkedAncestorWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = Path.Combine(directory.Path, "Owned"); + string targetPath = Path.Combine(directory.Path, "ExternalTarget"); + string linkedAncestor = Path.Combine(ownedRoot, "Linked"); + string targetChild = Path.Combine(targetPath, "Child"); + string candidatePath = Path.Combine(linkedAncestor, "Child"); + Directory.CreateDirectory(ownedRoot); + Directory.CreateDirectory(targetChild); + File.WriteAllText(Path.Combine(targetChild, "target.txt"), "target"); + CreateDirectoryJunction(linkedAncestor, targetPath); + + Action act = () => OwnedDirectoryTree.DeleteIfExists( + new OwnedContentPath(ownedRoot, candidatePath)); + + act.Should().Throw() + .WithMessage("*reparse point*"); + File.ReadAllText(Path.Combine(targetChild, "target.txt")).Should().Be("target"); + + Directory.Delete(linkedAncestor, recursive: false); + } + + private static void CreateDirectoryJunction(string linkPath, string targetPath) + { + var startInfo = new ProcessStartInfo + { + FileName = Environment.GetEnvironmentVariable("COMSPEC") ?? "cmd.exe", + Arguments = $"/d /c mklink /J \"{linkPath}\" \"{targetPath}\"", + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + }; + using Process process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Could not start the junction creation process."); + process.WaitForExit(); + + process.ExitCode.Should().Be( + 0, + $"junction creation should succeed. Output: {process.StandardOutput.ReadToEnd()} {process.StandardError.ReadToEnd()}"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs new file mode 100644 index 00000000..92a892cc --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs @@ -0,0 +1,800 @@ +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 GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Integrity.Services; + +public sealed class FileSystemContentIntegrityServiceTests +{ + [Fact] + public async Task VerifyAsyncReportsVerificationErrorWhenSnapshotCannotBeReadAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string snapshotDirectory = CreatePaths(directory.Path).IntegrityDirectory; + Directory.CreateDirectory(snapshotDirectory); + await File.WriteAllTextAsync(GetSnapshotPath(snapshotDirectory, "target"), "{"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + ContentIntegrityReport report = await service.VerifyAsync( + CreatePaths(directory.Path), + new[] { target }, + CancellationToken.None); + + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.VerificationError && + issue.Action == IntegrityIssueAction.Block && + issue.RelativePath == "." && + !String.IsNullOrWhiteSpace(issue.Message)); + } + + [Fact] + public async Task VerifyAsyncDetectsSameSizeSha256ModificationAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string filePath = Path.Combine(content, "file.bin"); + await File.WriteAllTextAsync(filePath, "aaaa"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(CreatePaths(directory.Path), target, CancellationToken.None); + await File.WriteAllTextAsync(filePath, "bbbb"); + + ContentIntegrityReport report = await service.VerifyAsync( + CreatePaths(directory.Path), + 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 VerifyAsyncCollectsMissingUnexpectedAndEmptyDirectoryIssuesAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string expectedPath = Path.Combine(content, "expected.txt"); + await File.WriteAllTextAsync(expectedPath, "expected"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(CreatePaths(directory.Path), target, CancellationToken.None); + File.Delete(expectedPath); + await File.WriteAllTextAsync(Path.Combine(content, "unexpected.txt"), "unexpected"); + Directory.CreateDirectory(Path.Combine(content, "nested", "empty")); + + ContentIntegrityReport report = await service.VerifyAsync( + CreatePaths(directory.Path), + 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 VerifyAsyncAlwaysReportsManagedEmptyDirectoriesAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(Path.Combine(content, "nested", "empty")); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(CreatePaths(directory.Path), target, CancellationToken.None); + + ContentIntegrityReport report = await service.VerifyAsync( + CreatePaths(directory.Path), + 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 VerifyAsyncClassifiesManagedSingleFileDifferencesAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string expectedPath = Path.Combine(content, "expected.txt"); + await File.WriteAllTextAsync(expectedPath, "expected"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedSingleFile); + await service.CaptureSnapshotAsync(CreatePaths(directory.Path), target, CancellationToken.None); + File.Delete(expectedPath); + await File.WriteAllTextAsync(Path.Combine(content, "unexpected.txt"), "unexpected"); + Directory.CreateDirectory(Path.Combine(content, "empty")); + + ContentIntegrityReport report = await service.VerifyAsync( + CreatePaths(directory.Path), + 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 VerifyAsyncClassifiesUnknownLegacyDifferencesForManualTrustAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string filePath = Path.Combine(content, "file.txt"); + await File.WriteAllTextAsync(filePath, "before"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.UnknownLegacy); + await service.CaptureSnapshotAsync(CreatePaths(directory.Path), target, CancellationToken.None); + await File.WriteAllTextAsync(filePath, "after"); + await File.WriteAllTextAsync(Path.Combine(content, "added.txt"), "added"); + + ContentIntegrityReport report = await service.VerifyAsync( + CreatePaths(directory.Path), + new[] { target }, + CancellationToken.None); + + report.Issues.Should().NotBeEmpty(); + report.Issues.Should().OnlyContain(issue => issue.Action == IntegrityIssueAction.TrustAsManual); + } + + [Fact] + public async Task VerifyAsyncMarksManualDifferencesForAbsorptionAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "before"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + await service.CaptureSnapshotAsync(CreatePaths(directory.Path), target, CancellationToken.None); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "after"); + await File.WriteAllTextAsync(Path.Combine(content, "added.txt"), "added"); + + ContentIntegrityReport report = await service.VerifyAsync( + CreatePaths(directory.Path), + new[] { target }, + CancellationToken.None); + + report.Issues.Should().NotBeEmpty() + .And.OnlyContain(issue => issue.Action == IntegrityIssueAction.Absorb); + } + + [Fact] + public async Task CaptureSnapshotAsyncAbsorbsManualDifferencesAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string filePath = Path.Combine(content, "file.txt"); + await File.WriteAllTextAsync(filePath, "before"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + await service.CaptureSnapshotAsync(CreatePaths(directory.Path), target, CancellationToken.None); + await File.WriteAllTextAsync(filePath, "after"); + + await service.CaptureSnapshotAsync(CreatePaths(directory.Path), target, CancellationToken.None); + ContentIntegrityReport report = await service.VerifyAsync( + CreatePaths(directory.Path), + new[] { target }, + CancellationToken.None); + + report.HasIssues.Should().BeFalse(); + } + + [Fact] + public async Task CaptureSnapshotAsyncCommitsCompleteDocumentThroughAtomicWriterAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "content"); + RecordingAtomicFileWriter atomicFileWriter = new(); + FileSystemContentIntegrityService service = CreateService(directory.Path, atomicFileWriter); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + using var cancellationTokenSource = new CancellationTokenSource(); + + await service.CaptureSnapshotAsync( + CreatePaths(directory.Path), + target, + cancellationTokenSource.Token); + + atomicFileWriter.WasWriteAsyncCalled.Should().BeTrue(); + atomicFileWriter.CancellationToken.Should().Be(cancellationTokenSource.Token); + atomicFileWriter.DestinationPath.Should().Be(GetSnapshotPath( + CreatePaths(directory.Path).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"); + } + + [Fact] + public async Task IntegritySnapshotsSwitchGameNamespaceWithoutRebuildingServiceAsync() + { + 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"); + 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 VerifyAsyncPreservesIgnoredInactiveCacheFileAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + await File.WriteAllTextAsync(Path.Combine(content, "active.png"), "active"); + await File.WriteAllTextAsync(Path.Combine(content, "inactive.png"), "inactive"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = new( + "target", + "Target", + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "inactive.png" }); + await service.CaptureSnapshotAsync(CreatePaths(directory.Path), target, CancellationToken.None); + + ContentIntegrityReport report = await service.VerifyAsync( + CreatePaths(directory.Path), + new[] { target }, + CancellationToken.None); + + report.HasIssues.Should().BeFalse(); + } + + [Fact] + public async Task CaptureSnapshotIfMatchesExpectedFileSetAsyncCapturesExistingManagedCacheWithoutMutationAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string activePath = Path.Combine(content, "active.png"); + string inactivePath = Path.Combine(content, "inactive.png"); + await File.WriteAllTextAsync(activePath, "active"); + await File.WriteAllTextAsync(inactivePath, "inactive"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = new( + "target", + "Target", + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "inactive.png" }); + + bool captured = await service.CaptureSnapshotIfMatchesExpectedFileSetAsync( + CreatePaths(directory.Path), + target, + new HashSet(StringComparer.OrdinalIgnoreCase) { "active.png" }, + CancellationToken.None); + ContentIntegrityReport report = await service.VerifyAsync( + CreatePaths(directory.Path), + new[] { target }, + CancellationToken.None); + + captured.Should().BeTrue(); + report.HasIssues.Should().BeFalse(); + File.ReadAllText(activePath).Should().Be("active"); + File.ReadAllText(inactivePath).Should().Be("inactive"); + } + + [Fact] + public async Task CaptureSnapshotIfMatchesExpectedFileSetAsyncRejectsExtrasWithoutSnapshottingAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + await File.WriteAllTextAsync(Path.Combine(content, "active.png"), "active"); + await File.WriteAllTextAsync(Path.Combine(content, "extra.png"), "extra"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + bool captured = await service.CaptureSnapshotIfMatchesExpectedFileSetAsync( + CreatePaths(directory.Path), + target, + new HashSet(StringComparer.OrdinalIgnoreCase) { "active.png" }, + CancellationToken.None); + ContentIntegrityReport report = await service.VerifyAsync( + CreatePaths(directory.Path), + new[] { target }, + CancellationToken.None); + + captured.Should().BeFalse(); + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == IntegrityIssueAction.Repair); + } + + [SymbolicLinkFact] + public async Task VerifyAsyncReportsIgnoredUnsafeLinkWithoutFollowingItAsync() + { + using TestDirectory directory = new(); + string outsidePath = Path.Combine(directory.Path, "outside.txt"); + await File.WriteAllTextAsync(outsidePath, "outside"); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string linkPath = Path.Combine(content, "inactive.png"); + SymbolicLinkTestSupport.CreateFileLink(linkPath, outsidePath); + + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = new( + "target", + "Target", + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "inactive.png" }); + + ContentIntegrityReport report = await service.VerifyAsync( + CreatePaths(directory.Path), + new[] { target }, + CancellationToken.None); + + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.UnsafeLink && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "inactive.png"); + File.ReadAllText(outsidePath).Should().Be("outside"); + } + + [Theory] + [InlineData(ContentSourceKind.ManagedS3, IntegrityIssueAction.Repair)] + [InlineData(ContentSourceKind.ManagedSingleFile, IntegrityIssueAction.Redownload)] + [InlineData(ContentSourceKind.Manual, IntegrityIssueAction.Absorb)] + [InlineData(ContentSourceKind.UnknownLegacy, IntegrityIssueAction.TrustAsManual)] + public async Task VerifyAsyncClassifiesUntrackedContentBySourceAsync( + ContentSourceKind sourceKind, + IntegrityIssueAction expectedAction) + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, sourceKind); + + ContentIntegrityReport report = await service.VerifyAsync( + CreatePaths(directory.Path), + new[] { target }, + CancellationToken.None); + + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == expectedAction); + } + + [Fact] + public async Task VerifyAsyncRequiresMigrationWhenSourceClassificationChangesAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "content"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget managedTarget = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync( + CreatePaths(directory.Path), + managedTarget, + CancellationToken.None); + ContentIntegrityTarget manualTarget = CreateTarget(content, ContentSourceKind.Manual); + + ContentIntegrityReport report = await service.VerifyAsync( + CreatePaths(directory.Path), + new[] { manualTarget }, + CancellationToken.None); + + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == IntegrityIssueAction.Absorb); + } + + [Fact] + public async Task ApplyCleanupAsyncDeletesConfirmedManagedExtrasAndEmptyDirectoriesAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + string nested = Path.Combine(content, "nested"); + Directory.CreateDirectory(nested); + await File.WriteAllTextAsync(Path.Combine(nested, "unexpected.txt"), "unexpected"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "nested/unexpected.txt"), + }); + + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + File.Exists(Path.Combine(nested, "unexpected.txt")).Should().BeFalse(); + Directory.Exists(nested).Should().BeFalse(); + } + + [Fact] + public async Task ApplyCleanupAsyncDeletesConfirmedDirectoryIssueAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + string unexpected = Path.Combine(content, "unexpected"); + Directory.CreateDirectory(unexpected); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.EmptyDirectory, + IntegrityIssueAction.Delete, + "unexpected"), + }); + + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + Directory.Exists(unexpected).Should().BeFalse(); + } + + [Fact] + public async Task ApplyCleanupAsyncRejectsUnknownTargetAsync() + { + using TestDirectory directory = new(); + FileSystemContentIntegrityService service = CreateService(directory.Path); + 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 ApplyCleanupAsyncIgnoresMissingDeletedEntriesAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "missing.txt"), + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "missing/missing.txt"), + }); + + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + Directory.Exists(content).Should().BeTrue(); + } + + [Fact] + public async Task ApplyCleanupAsyncSkipsEmptyDirectorySweepWhenRootIsMissingAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "missing-content"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "missing.txt"), + }); + + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + Directory.Exists(content).Should().BeFalse(); + } + + [Fact] + public async Task ApplyCleanupAsyncPreservesIgnoredAndNonEmptyDirectoriesAsync() + { + 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"); + await File.WriteAllTextAsync(Path.Combine(nonEmpty, "keep.txt"), "keep"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget( + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.Ordinal) { @"\IGNORED/" }); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "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(); + } + + [SymbolicLinkFact] + public async Task VerifyAsyncRejectsManualLinkWithoutFollowingItAsync() + { + using TestDirectory directory = new(); + string outsidePath = Path.Combine(directory.Path, "outside.txt"); + await File.WriteAllTextAsync(outsidePath, "outside"); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string linkPath = Path.Combine(content, "linked.txt"); + SymbolicLinkTestSupport.CreateFileLink(linkPath, outsidePath); + + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + + ContentIntegrityReport report = await service.VerifyAsync( + CreatePaths(directory.Path), + 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( + CreatePaths(directory.Path), + target, + CancellationToken.None); + await capture.Should().ThrowAsync(); + } + + [SymbolicLinkFact] + public async Task VerifyAsyncReportsLinkedTargetRootWithoutFollowingItAsync() + { + using TestDirectory directory = new(); + string outside = Path.Combine(directory.Path, "outside"); + Directory.CreateDirectory(outside); + string outsideFile = Path.Combine(outside, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside"); + string content = Path.Combine(directory.Path, "content"); + SymbolicLinkTestSupport.CreateDirectoryLink(content, outside); + + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + ContentIntegrityReport report = await service.VerifyAsync( + CreatePaths(directory.Path), + new[] { target }, + CancellationToken.None); + + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.UnsafeLink && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "."); + File.ReadAllText(outsideFile).Should().Be("outside"); + } + + [SymbolicLinkFact] + public async Task ApplyCleanupAsyncDeletesLinkedTargetRootWithoutDeletingTargetAsync() + { + using TestDirectory directory = new(); + string outside = Path.Combine(directory.Path, "outside"); + Directory.CreateDirectory(outside); + string outsideFile = Path.Combine(outside, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside"); + string content = Path.Combine(directory.Path, "content"); + SymbolicLinkTestSupport.CreateDirectoryLink(content, outside); + + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnsafeLink, + IntegrityIssueAction.Delete, + "."), + }); + + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + Directory.Exists(content).Should().BeFalse(); + File.ReadAllText(outsideFile).Should().Be("outside"); + } + + [SymbolicLinkFact] + public async Task ApplyCleanupAsyncRejectsLinkedAncestorWithoutDeletingTargetAsync() + { + using TestDirectory directory = new(); + string outside = Path.Combine(directory.Path, "outside"); + Directory.CreateDirectory(outside); + string outsideFile = Path.Combine(outside, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside"); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + SymbolicLinkTestSupport.CreateDirectoryLink(Path.Combine(content, "linked"), outside); + + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "linked/outside.txt"), + }); + + Func cleanup = () => service.ApplyCleanupAsync( + report, + new[] { target }, + CancellationToken.None); + + await cleanup.Should().ThrowAsync(); + File.ReadAllText(outsideFile).Should().Be("outside"); + } + + [Fact] + public async Task ApplyCleanupAsyncRejectsPathTraversalAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string outsideFile = Path.Combine(directory.Path, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "../outside.txt"), + }); + + Func cleanup = () => service.ApplyCleanupAsync( + report, + new[] { target }, + CancellationToken.None); + + await cleanup.Should().ThrowAsync(); + File.ReadAllText(outsideFile).Should().Be("outside"); + } + + private static FileSystemContentIntegrityService CreateService( + string root, + IAtomicFileWriter? atomicFileWriter = null) + { + return new FileSystemContentIntegrityService( + atomicFileWriter ?? new AtomicFileWriter(), + NullLogger.Instance); + } + + private static LauncherPaths CreatePaths(string root) + { + return TestLauncherPaths.Create(Path.Combine(root, "Game")); + } + + 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 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/Launching/Services/DeploymentLaunchPreparationServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/DeploymentLaunchPreparationServiceTests.cs new file mode 100644 index 00000000..97b5711e --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/DeploymentLaunchPreparationServiceTests.cs @@ -0,0 +1,188 @@ +using System; +using System.IO; +using System.Security.Cryptography; +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.Common; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Launching.Support; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class DeploymentLaunchPreparationServiceTests +{ + [Fact] + public void PrepareResolvesSelectedVersionPathsAndUsesSelectionOrderForPrecedence() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + LauncherContentVersion[] versions = + [ + CreateVersion(ModificationType.Mod, "Rise", "1.0"), + CreateVersion(ModificationType.Patch, "Balance", "2.0", "Rise"), + CreateVersion(ModificationType.Addon, "Maps", "3.0", "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, + disableBaseGameScriptFiles: false), + CancellationToken.None); + + succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("addon"); + } + + [Fact] + public void PrepareDisablesBaseGameScriptsAndCleanupRestoresThem() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + LauncherContentVersion version = CreateVersion(ModificationType.Mod, "Rise", "1.0"); + Directory.CreateDirectory(GetVersionRoot(paths, version)); + 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"); + DeploymentLaunchPreparationService service = CreateService(); + + bool prepareSucceeded = service.Prepare( + new LaunchPreparationRequest( + paths, + new[] { version }, + disableBaseGameScriptFiles: true), + CancellationToken.None); + + prepareSucceeded.Should().BeTrue(); + File.Exists(multiplayerScripts).Should().BeFalse(); + File.Exists(scriptsIni).Should().BeFalse(); + + bool cleanupSucceeded = service.Cleanup(paths, CancellationToken.None); + + cleanupSucceeded.Should().BeTrue(); + File.ReadAllText(multiplayerScripts).Should().Be("multiplayer"); + File.ReadAllText(scriptsIni).Should().Be("scripts"); + } + + [Fact] + public void PrepareReportsDeploymentFailure() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + DeploymentLaunchPreparationService service = CreateService(); + + bool succeeded = service.Prepare( + new LaunchPreparationRequest( + paths, + new[] { CreateVersion(ModificationType.Mod, "Missing", "1.0") }, + disableBaseGameScriptFiles: false), + CancellationToken.None); + + succeeded.Should().BeFalse(); + } + + [Fact] + public void RecoverRestoresJournaledBackupThroughLaunchPreparationBoundary() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + string journalPath = Path.Combine(deploymentRoot, "journal.jsonl"); + DeploymentStateStore.AppendJournal( + journalPath, + DeploymentJournalRecord.DeploymentStarted( + "crash", + PhysicalDirectoryPath.ResolveExisting(paths.GameDirectory), + DeploymentStateStore.GetGameRootIdentity(paths.GameDirectory), + paths.Game)); + byte[] originalBytes = File.ReadAllBytes(backupPath); + DeploymentStateStore.AppendJournal( + journalPath, + DeploymentJournalRecord.FileBackedUp( + "Data/file.ini", + "Backups/crash/Data/file.ini", + new DeploymentFileFingerprint( + originalBytes.Length, + Convert.ToHexString(SHA256.HashData(originalBytes))), + "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 CopyOnlyHardLinkCreator(), + NullLogger.Instance); + return new DeploymentLaunchPreparationService( + deploymentEngine, + NullLogger.Instance); + } + + private static LauncherContentVersion CreateVersion( + ModificationType modificationType, + string name, + string version, + string parentContentName = "") + { + return new LauncherContentVersion + { + ModificationType = modificationType, + Name = name, + Version = version, + ParentContentName = parentContentName, + }; + } + + private static LauncherPaths CreatePaths(string root) + { + string gameDirectory = Path.Combine(root, "Game"); + Directory.CreateDirectory(gameDirectory); + + return TestLauncherPaths.Create(gameDirectory); + } + + 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; + } + + private sealed class CopyOnlyHardLinkCreator : IHardLinkCreator + { + public bool TryCreateHardLink(string targetPath, string sourcePath) + { + return false; + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemDeploymentServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemDeploymentServiceTests.cs new file mode 100644 index 00000000..b8420de2 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemDeploymentServiceTests.cs @@ -0,0 +1,1130 @@ +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.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 GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class FileSystemDeploymentServiceTests +{ + [Fact] + public void PrepareAsyncUsesHardLinkWhenCreatorSucceedsAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FakeHardLinkCreator hardLinks = new(canCreate: true); + FileSystemDeploymentService service = CreateService(hardLinks); + + 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 PrepareAsyncCopiesFileWhenHardLinkCreatorFailsAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + 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"); + Directory.EnumerateFiles(paths.GameDirectory, "*.tmp", SearchOption.AllDirectories).Should().BeEmpty(); + } + + [Fact] + public void CleanupAsyncRestoresBackedUpOriginalFileAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + Directory.CreateDirectory(Path.Combine(paths.GameDirectory, "Data")); + File.WriteAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini"), "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + 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 CleanupRestoresEqualContentOriginalInsteadOfLeavingDeployedHardLink() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "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 PrepareCopiesReadOnlyPackageFileWithoutChangingPackageAttributes() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + 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(canCreate: true); + 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 CleanupLeavesModifiedDeployedFileAndOriginalBackupUntouched() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + 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 CleanupDoesNotDeleteModifiedDeploymentWithoutOriginalBackup() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + 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 CleanupAsyncDoesNotDeleteRestoredOriginalWhenManifestWasAlreadyAppliedAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + 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 CleanupAsyncRemovesCreatedDirectoriesOnlyWhenEmptyAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("Data/Sub/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + 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 PrepareAsyncDeploysGibSourceAsBigTargetAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("PatchData.gib", "archive")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + 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 PrepareAsyncLetsHigherPrecedencePackageWinTargetConflictAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string modRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string addonRoot = CreatePackage(paths, "Addon", ("Data/file.ini", "addon")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + 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 PrepareAsyncDisablesExistingRequestedFilesAndCleanupRestoresThemAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + 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(canCreate: 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 PrepareNormalizesAndDeduplicatesDisabledTargets() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + 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(canCreate: false)); + + DeploymentResult result = service.Prepare( + paths, + Array.Empty(), + new[] { @"Data\Scripts\Scripts.ini", "Data/Scripts/Scripts.ini", " " }, + CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.Exists(scriptsIni).Should().BeFalse(); + } + + [Fact] + public void PrepareAsyncReusesDisabledFileBackupWhenPackageDeploysSameTargetAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + 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(canCreate: 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 PrepareAsyncRecoversPartialDeploymentWhenLaterFileFailsAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + Directory.CreateDirectory(Path.Combine(paths.GameDirectory, "A")); + File.WriteAllText(Path.Combine(paths.GameDirectory, "A", "file.ini"), "original"); + File.WriteAllText(Path.Combine(paths.GameDirectory, "B"), "not-a-directory"); + string packageRoot = CreatePackage( + paths, + "Mod", + ("A/file.ini", "mod"), + ("B/file.ini", "blocked")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "A", "file.ini")).Should().Be("original"); + File.ReadAllText(Path.Combine(paths.GameDirectory, "B")).Should().Be("not-a-directory"); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void PrepareDoesNotTranslateCancellationIntoFailure() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + 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 PrepareCancellationAfterMutationRecoversPartialDeploymentBeforeRethrowing() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage( + paths, + "Mod", + ("A/first.ini", "first"), + ("B/second.ini", "second")); + using var cancellationSource = new CancellationTokenSource(); + FileSystemDeploymentService service = CreateService( + new CancelingHardLinkCreator(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 PrepareCancellationDuringFinalFileRecoversBeforeRethrowing() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + using var cancellationSource = new CancellationTokenSource(); + FileSystemDeploymentService service = CreateService( + new CancelingWindowsHardLinkCreator(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 PrepareAsyncFailsWhenDeploymentLockIsHeldAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + using FileStream lockStream = new( + Path.Combine(deploymentRoot, "deployment.lock"), + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + 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 PrepareFailsWhenDeploymentLockIsDanglingSymbolicLink() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + Directory.CreateDirectory(paths.DeploymentDirectory); + 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(canCreate: 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 PrepareFailsWhenDeploymentJournalIsDanglingSymbolicLink() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + Directory.CreateDirectory(paths.DeploymentDirectory); + 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(canCreate: 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 PrepareAsyncFailsWhenPackageTreeContainsReparsePointAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = Path.Combine(paths.ModsDirectory, "Mod"); + string linkTarget = Path.Combine(directory.Path, "linked-package-content"); + string linkPath = Path.Combine(packageRoot, "Linked"); + Directory.CreateDirectory(packageRoot); + Directory.CreateDirectory(linkTarget); + SymbolicLinkTestSupport.CreateDirectoryLink(linkPath, linkTarget); + + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + } + + [SymbolicLinkFact] + public void PrepareAsyncFailsWhenGameTargetParentIsReparsePointAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string linkedTarget = Path.Combine(directory.Path, "outside-game-data"); + string linkedDataDirectory = Path.Combine(paths.GameDirectory, "Data"); + Directory.CreateDirectory(linkedTarget); + SymbolicLinkTestSupport.CreateDirectoryLink(linkedDataDirectory, linkedTarget); + + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: 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(); + } + + [Fact] + public void CleanupAsyncRestoresBackedUpFileFromJournalWithoutActiveManifestAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + WriteJournal( + paths, + DeploymentJournalRecord.FileBackedUp( + "Data/file.ini", + "Backups/crash/Data/file.ini", + CreateFingerprint("original"), + "Backups/crash/Data/file.ini.partial")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + DeploymentResult result = service.Cleanup(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 RecoverAsyncRestoresBackupStartedFileWhenMoveCompletedBeforeBackedUpJournalAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "original"); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + WriteJournal( + 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(canCreate: 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 RecoverAsyncIgnoresBackupStartedRecordWhenBackupWasNotCreatedAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "original"); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + WriteJournal( + paths, + DeploymentJournalRecord.FileBackupStarted( + "Data/file.ini", + "Backups/crash/Data/file.ini", + "Backups/crash/Data/file.ini.partial")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: 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 RecoverAsyncRestoresBackupWhenCleanupRestoreStartedAndBackupStillExistsAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "mod"); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + DeploymentFileFingerprint originalFingerprint = CreateFingerprint("original"); + DeploymentFileFingerprint modFingerprint = CreateFingerprint("mod"); + WriteJournal( + 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(canCreate: 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 RecoverAsyncTreatsMissingBackupAfterCleanupRestoreStartedAsRestoredAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "original"); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + File.WriteAllText(Path.Combine(deploymentRoot, "active.json"), "{not-json"); + DeploymentFileFingerprint originalFingerprint = CreateFingerprint("original"); + WriteJournal( + 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", + CreateFingerprint("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(canCreate: 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 RecoverAsyncKeepsNoBackupFileDeletedWhenCleanupDeleteCompletedAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + File.WriteAllText(Path.Combine(deploymentRoot, "active.json"), "{not-json"); + WriteJournal( + paths, + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + backupRelativePath: null, + deployedFingerprint: CreateFingerprint("mod"), + backupFingerprint: null, + stagingRelativePath: "Data/.file.ini.deploy.tmp"), + DeploymentJournalRecord.FileCleanupDeleted("Data/file.ini")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: 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 RecoverAsyncRestoresBackedUpFileFromJournalWithoutActiveManifestAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + WriteJournal( + paths, + DeploymentJournalRecord.FileBackedUp( + "Data/file.ini", + "Backups/crash/Data/file.ini", + CreateFingerprint("original"), + "Backups/crash/Data/file.ini.partial")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: 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(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void RecoverAcceptsSchemaTwoManifestWithRetiredReportingFields() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + 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 RecoverAcceptsRetiredJournalFieldsWhenActiveManifestIsCorrupt() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string deployedPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "mod"); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + File.WriteAllText(Path.Combine(deploymentRoot, "active.json"), "{not-json"); + WriteJournal( + paths, + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + backupRelativePath: null, + deployedFingerprint: CreateFingerprint("mod"), + backupFingerprint: null, + stagingRelativePath: "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(canCreate: 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 RecoverAsyncRemovesFileFromStartedJournalRecordAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string deployedPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "mod"); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + WriteJournal( + paths, + DeploymentJournalRecord.FileDeploymentStarted( + "Data/file.ini", + backupRelativePath: null, + deployedFingerprint: CreateFingerprint("mod"), + backupFingerprint: null, + stagingRelativePath: "Data/.file.ini.deploy.tmp")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: 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(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RecoverSafelyReplaysDirectoryCreationIntent(bool directoryWasCreated) + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string createdDirectoryPath = Path.Combine(paths.GameDirectory, "Data", "Sub"); + if (directoryWasCreated) + { + Directory.CreateDirectory(createdDirectoryPath); + } + + WriteJournal(paths, DeploymentJournalRecord.DirectoryCreated("Data/Sub")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: 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 RecoverRefusesJournalBoundToDifferentPhysicalGameDirectory() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "mod"); + string otherGameDirectory = Path.Combine(directory.Path, "OtherGame"); + Directory.CreateDirectory(otherGameDirectory); + Directory.CreateDirectory(paths.DeploymentDirectory); + DeploymentJournalRecord[] records = + { + DeploymentJournalRecord.DeploymentStarted( + "crash", + PhysicalDirectoryPath.ResolveExisting(otherGameDirectory), + DeploymentStateStore.GetGameRootIdentity(otherGameDirectory), + SupportedGame.Generals), + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + backupRelativePath: null, + deployedFingerprint: CreateFingerprint("mod"), + backupFingerprint: null, + stagingRelativePath: "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(canCreate: 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(); + } + + [Fact] + public void RecoverRefusesActiveManifestForDifferentGameRootWhenJournalIsEmpty() + { + using var directory = new TestDirectory(); + LauncherPaths originalPaths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(originalPaths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: 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(); + } + + private static FileSystemDeploymentService CreateService(IHardLinkCreator hardLinkCreator) + { + return new FileSystemDeploymentService( + hardLinkCreator, + NullLogger.Instance); + } + + private static LauncherPaths CreatePaths(string root) + { + string gameDirectory = Path.Combine(root, "Game"); + string executableDirectory = Path.Combine(root, "Launcher"); + Directory.CreateDirectory(gameDirectory); + Directory.CreateDirectory(executableDirectory); + + LauncherPaths paths = new LauncherStoragePaths(executableDirectory) + .CreateGamePaths(SupportedGame.Generals, gameDirectory); + Directory.CreateDirectory(paths.OwnedGameDataDirectory); + return paths; + } + + private static string CreatePackage( + LauncherPaths paths, + string name, + params (string RelativePath, string Contents)[] files) + { + string packageRoot = Path.Combine(paths.ModsDirectory, name); + foreach ((string relativePath, string contents) in files) + { + string filePath = Path.Combine(packageRoot, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllText(filePath, contents); + } + + return packageRoot; + } + + private static void WriteJournal(LauncherPaths paths, params DeploymentJournalRecord[] records) + { + Directory.CreateDirectory(paths.DeploymentDirectory); + var header = DeploymentJournalRecord.DeploymentStarted( + "crash", + PhysicalDirectoryPath.ResolveExisting(paths.GameDirectory), + DeploymentStateStore.GetGameRootIdentity(paths.GameDirectory), + paths.Game); + var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); + File.WriteAllLines( + Path.Combine(paths.DeploymentDirectory, "journal.jsonl"), + new[] { header }.Concat(records).Select(record => JsonSerializer.Serialize(record, serializerOptions))); + } + + private static DeploymentFileFingerprint CreateFingerprint(string contents) + { + byte[] bytes = Encoding.UTF8.GetBytes(contents); + return new DeploymentFileFingerprint(bytes.Length, Convert.ToHexString(SHA256.HashData(bytes))); + } + + private static DeploymentPackage CreateDeploymentPackage( + string root, + int precedence) + { + return new DeploymentPackage(root, precedence); + } + + private sealed class FakeHardLinkCreator : IHardLinkCreator + { + private readonly bool _canCreate; + + public FakeHardLinkCreator(bool canCreate) + { + _canCreate = canCreate; + } + + public List<(string TargetPath, string SourcePath)> CreatedLinks { get; } = new(); + + public bool TryCreateHardLink(string targetPath, string sourcePath) + { + if (!_canCreate) + { + return false; + } + + File.Copy(sourcePath, targetPath); + CreatedLinks.Add((targetPath, sourcePath)); + return true; + } + } + + private sealed class CancelingHardLinkCreator : IHardLinkCreator + { + private readonly CancellationTokenSource _cancellationSource; + + public CancelingHardLinkCreator(CancellationTokenSource cancellationSource) + { + _cancellationSource = cancellationSource; + } + + public bool TryCreateHardLink(string targetPath, string sourcePath) + { + _cancellationSource.Cancel(); + return false; + } + } + + private sealed class CancelingWindowsHardLinkCreator : IHardLinkCreator + { + private readonly CancellationTokenSource _cancellationSource; + + private readonly WindowsHardLinkCreator _hardLinkCreator = new(); + + public CancelingWindowsHardLinkCreator(CancellationTokenSource cancellationSource) + { + _cancellationSource = cancellationSource; + } + + public bool TryCreateHardLink(string targetPath, string sourcePath) + { + bool created = _hardLinkCreator.TryCreateHardLink(targetPath, sourcePath); + _cancellationSource.Cancel(); + return created; + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionServiceTests.cs new file mode 100644 index 00000000..1b4485df --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionServiceTests.cs @@ -0,0 +1,710 @@ +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.Tests.Testing; +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 = CreatePaths(testDirectory.Path); + LauncherContentVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + ContentIntegrityTarget target = CreateTarget("package", Path.Combine(paths.ModsDirectory, "ShockWave", "1.2")); + LaunchContentIntegrityTargetContext[] contexts = + [ + new LaunchContentIntegrityTargetContext(target, version, isCache: 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 = CreatePaths(testDirectory.Path); + LauncherContentVersion version = CreateVersion( + ContentSourceKind.ManagedSingleFile, + imageSourceLink: imageSourceLink); + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + paths.GetModificationImagesDirectory("ShockWave"), + ContentSourceKind.ManagedSingleFile); + LaunchContentIntegrityTargetContext cacheContext = new(cacheTarget, version, isCache: 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 ResolveAsync_RefreshesManagedCacheAndPreservesIgnoredFilesAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + 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"); + await File.WriteAllTextAsync(staleFilePath, "stale"); + await File.WriteAllTextAsync(ignoredFilePath, "ignored"); + + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + cacheRoot, + ContentSourceKind.ManagedSingleFile, + new HashSet(StringComparer.OrdinalIgnoreCase) { "ignored.txt" }); + LaunchContentIntegrityTargetContext cacheContext = new(cacheTarget, version, isCache: true); + ContentIntegrityReport report = CreateReport( + "cache", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + var assetDownloader = new RecordingRemoteAssetDownloader(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + assetDownloader: assetDownloader); + RecordingResolutionProgress progress = new(); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { cacheContext }), + progress, + CancellationToken.None); + + File.Exists(staleFilePath).Should().BeFalse(); + File.Exists(ignoredFilePath).Should().BeTrue(); + 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 = CreatePaths(testDirectory.Path); + LauncherContentVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.ManagedSingleFile); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, isCache: false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.MissingFile, + IntegrityIssueAction.Repair); + PackageUpdateProgress packageProgress = new(100, 40, 40, "package.zip"); + var singleFilePackageUpdater = new RecordingSingleFilePackageUpdater(packageProgress); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + singleFilePackageUpdater: singleFilePackageUpdater); + RecordingResolutionProgress progress = new(); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + progress, + CancellationToken.None); + + (Uri SourceUri, PackageUpdatePathSet Paths) updateRequest = + singleFilePackageUpdater.Requests.Should().ContainSingle().Which; + updateRequest.SourceUri.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_RepairsManagedS3PackageFileInPlaceUsingManifestAndTargetRootAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + LauncherContentVersion version = CreateS3Version(); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.ManagedS3); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, isCache: false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair, + "Data/file.gib"); + RemoteFileManifestEntry[] files = + { + new RemoteFileManifestEntry("Data/file.big", "0123456789ABCDEF0123456789ABCDEF", 10), + new RemoteFileManifestEntry("Data/readme.txt", "0123456789ABCDEF0123456789ABCDEF", 5), + }; + var manifestReader = new StubS3ObjectManifestReader(files); + PackageUpdateProgress packageProgress = new(10, 10, 100, "Data/file.big"); + var s3PackageUpdater = new RecordingS3PackageUpdater(packageProgress); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + manifestReader: manifestReader, + s3PackageUpdater: s3PackageUpdater); + RecordingResolutionProgress progress = new(); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + progress, + CancellationToken.None); + + manifestReader.Requests.Should().ContainSingle().Which.Should().Match(request => + request.Endpoint == "https://s3.example.test" && + request.BucketName == "mods" && + 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 = CreatePaths(testDirectory.Path); + LauncherContentVersion version = CreateS3Version(); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.ManagedS3); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, isCache: false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Repair, + "."); + RemoteFileManifestEntry[] files = + { + new RemoteFileManifestEntry("Data/file.big", "0123456789ABCDEF0123456789ABCDEF", 10), + new RemoteFileManifestEntry("Data/readme.txt", "0123456789ABCDEF0123456789ABCDEF", 5), + }; + var manifestReader = new StubS3ObjectManifestReader(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); + } + + [Fact] + public async Task RegisterManualImportAsync_MarksVersionManualAndCapturesPackageAndCacheSnapshotsAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + 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 LaunchContentIntegrityTargetContext(packageTarget, version, isCache: false), + new LaunchContentIntegrityTargetContext(cacheTarget, version, isCache: 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_CapturesPackageAndRefreshesMismatchedImageCacheAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + LauncherContentVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + LauncherContentVersion inactiveVersion = CreateVersion( + ContentSourceKind.ManagedSingleFile, + version: "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"); + await File.WriteAllTextAsync(staleFilePath, "stale"); + await File.WriteAllTextAsync(inactiveImagePath, "inactive"); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + var assetDownloader = new RecordingRemoteAssetDownloader(); + 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().ContainSingle().Which.Should().Be("1.1.jpg"); + conditionalSnapshot.ExpectedRelativePaths.Should().BeEquivalentTo("1.2.jpg"); + + File.Exists(staleFilePath).Should().BeFalse(); + File.Exists(inactiveImagePath).Should().BeTrue(); + assetDownloader.Calls.Should().BeEquivalentTo(new[] + { + ( + new Uri("https://cdn.example.test/card.jpg"), + Path.Combine(cacheRoot, "1.2.jpg")), + }); + integrityService.CapturedTargets.Should().HaveCount(2); + integrityService.CapturedTargets[1].Should().Be(conditionalSnapshot.Target); + } + + [Fact] + public async Task CaptureManualImageSnapshotAsync_CapturesOnlyResolvedManualImageCacheAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + LauncherContentVersion version = CreateVersion( + ContentSourceKind.Manual, + simpleDownloadLink: string.Empty); + LauncherContentVersion inactiveVersion = CreateVersion( + ContentSourceKind.Manual, + version: "1.1", + simpleDownloadLink: string.Empty); + string cacheRoot = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(cacheRoot); + await File.WriteAllTextAsync(Path.Combine(cacheRoot, "1.1-background.png"), "inactive"); + 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("1.1-background.png"); + integrityService.ConditionalSnapshotRequests.Should().BeEmpty(); + catalog.SaveCount.Should().Be(0); + } + + 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) + { + return new FileSystemLaunchContentIntegrityResolutionService( + integrityService ?? CreateIntegrityService(), + targetBuilder ?? new StubLaunchContentIntegrityTargetBuilder(), + manifestReader ?? new StubS3ObjectManifestReader(), + 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 new ContentIntegrityReport(new[] + { + 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)); + } + + 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") + { + return new LauncherContentVersion + { + Installation = new LauncherContentInstallation { ContentSourceKind = sourceKind }, + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = version, + SimpleDownloadLink = simpleDownloadLink, + UIImageSourceLink = imageSourceLink, + }; + } + + private static LauncherContentVersion CreateS3Version() + { + return new LauncherContentVersion + { + Installation = new LauncherContentInstallation { ContentSourceKind = ContentSourceKind.ManagedS3 }, + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2", + S3HostLink = "https://s3.example.test", + S3BucketName = "mods", + S3FolderName = "ShockWave/1.2", + }; + } + + private static LauncherPaths CreatePaths(string root) + { + return TestLauncherPaths.Create(Path.Combine(root, "Game")); + } + + private sealed class RecordingResolutionProgress : IProgress + { + public List Reports { get; } = new(); + + public void Report(LaunchContentIntegrityResolutionProgress value) + { + Reports.Add(value); + } + } + + private sealed class StubS3ObjectManifestReader : IS3ObjectManifestReader + { + private readonly IReadOnlyList _files; + + public StubS3ObjectManifestReader() + : this(Array.Empty()) + { + } + + public StubS3ObjectManifestReader(IReadOnlyList files) + { + _files = files; + } + + public List Requests { get; } = new(); + + public Task> ReadManifestAsync( + S3ObjectManifestRequest request, + CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(_files); + } + } + + private sealed class RecordingS3PackageUpdater : IS3PackageUpdater + { + private readonly PackageUpdateProgress? _repairProgress; + + public RecordingS3PackageUpdater(PackageUpdateProgress? repairProgress = null) + { + _repairProgress = repairProgress; + } + + public List UpdateRequests { get; } = new(); + + public List RepairRequests { get; } = new(); + + public Task UpdateAsync( + S3PackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + UpdateRequests.Add(request); + return Task.CompletedTask; + } + + public Task RepairFilesAsync( + S3PackageFileRepairRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + RepairRequests.Add(request); + + if (_repairProgress is not null) + { + progress?.Report(_repairProgress); + } + + return Task.CompletedTask; + } + } + + private sealed class RecordingContentIntegrityService : IContentIntegrityService + { + public ContentIntegrityReport VerificationReport { get; set; } = + new(Array.Empty()); + + public bool CaptureIfMatchesExpectedFileSetResult { get; set; } + + public List> VerifiedTargetSets { get; } = new(); + + public List VerifiedPaths { get; } = new(); + + public List<( + ContentIntegrityTarget Target, + IReadOnlySet ExpectedRelativePaths)> ConditionalSnapshotRequests + { get; } = new(); + + public List ConditionalSnapshotPaths { get; } = new(); + + public List CapturedTargets { get; } = new(); + + public List CapturedPaths { get; } = new(); + + public Task VerifyAsync( + LauncherPaths paths, + IReadOnlyList targets, + CancellationToken cancellationToken) + { + VerifiedPaths.Add(paths); + VerifiedTargetSets.Add(targets); + return Task.FromResult(VerificationReport); + } + + public Task CaptureSnapshotIfMatchesExpectedFileSetAsync( + LauncherPaths paths, + ContentIntegrityTarget target, + IReadOnlySet expectedRelativePaths, + CancellationToken cancellationToken) + { + ConditionalSnapshotPaths.Add(paths); + ConditionalSnapshotRequests.Add((target, expectedRelativePaths)); + return Task.FromResult(CaptureIfMatchesExpectedFileSetResult); + } + + public Task CaptureSnapshotAsync( + LauncherPaths paths, + ContentIntegrityTarget target, + CancellationToken cancellationToken) + { + CapturedPaths.Add(paths); + CapturedTargets.Add(target); + return Task.CompletedTask; + } + + public Task ApplyCleanupAsync( + ContentIntegrityReport report, + IReadOnlyList targets, + CancellationToken cancellationToken) + { + 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; } = new(); + + public IReadOnlyList BuildTargets( + LaunchContentIntegrityTargetRequest request) + { + Requests.Add(request); + return _contexts; + } + } + + private sealed class RecordingSingleFilePackageUpdater : ISingleFilePackageUpdater + { + private readonly PackageUpdateProgress? _progressToReport; + + public RecordingSingleFilePackageUpdater(PackageUpdateProgress? progressToReport = null) + { + _progressToReport = progressToReport; + } + + public List<(Uri SourceUri, PackageUpdatePathSet Paths)> Requests { get; } = new(); + + public Task UpdateAsync( + Uri sourceUri, + PackageUpdatePathSet paths, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + Requests.Add((sourceUri, paths)); + + if (_progressToReport is not null) + { + progress?.Report(_progressToReport); + } + + return Task.CompletedTask; + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilderTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilderTests.cs new file mode 100644 index 00000000..0fe9b7f5 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilderTests.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class FileSystemLaunchContentIntegrityTargetBuilderTests +{ + [Fact] + public void BuildTargetsUsesLauncherOwnedTempPathsAndIgnoresInactiveCacheFiles() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + Directory.CreateDirectory(paths.ModsDirectory); + Directory.CreateDirectory(paths.ImagesDirectory); + 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, "0.9-background.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", "0.9-background.jpg"); + cacheTarget.Target.RootDirectory.Should().StartWith(paths.ImagesDirectory); + } + + private static LauncherContentVersion CreateVersion(string name, string version) + { + return new LauncherContentVersion + { + Installation = new LauncherContentInstallation { ContentSourceKind = ContentSourceKind.Manual }, + ModificationType = ModificationType.Mod, + Name = name, + Version = version, + }; + } + + private static LauncherPaths CreatePaths(string root) + { + return TestLauncherPaths.Create(Path.Combine(root, "Game")); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryServiceTests.cs new file mode 100644 index 00000000..494c8878 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryServiceTests.cs @@ -0,0 +1,208 @@ +using System; +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 GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class WindowsGameExecutableDiscoveryServiceTests +{ + [Fact] + public void GetGameClientsReturnsCommunityThenGeneralsOnlineWithAvailability() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + CreateGameFile(paths, "generalszh.exe"); + CreateGameFile(paths, "generalsonlinezh.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + IReadOnlyList clients = service.GetGameClients(); + + clients.Should().HaveCount(2); + clients[0].ExecutableName.Should().Be("generalszh.exe"); + clients[0].Kind.Should().Be(GameClientExecutableKind.Community); + clients[0].IsAvailable.Should().BeTrue(); + clients[1].ExecutableName.Should().Be("generalsonlinezh.exe"); + clients[1].Kind.Should().Be(GameClientExecutableKind.GeneralsOnline); + clients[1].IsAvailable.Should().BeTrue(); + } + + [Fact] + public void GetGameClientsKeepsMissingBuiltInsVisible() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + IReadOnlyList clients = service.GetGameClients(); + + clients.Select(client => client.ExecutableName).Should() + .Equal("generalszh.exe", "generalsonlinezh.exe"); + clients.Should().OnlyContain(client => !client.IsAvailable); + } + + [Fact] + public void GetGameClientsUsesManagedGeneralsCommunityExecutable() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create( + Path.Combine(directory.Path, "Game"), + SupportedGame.Generals); + CreateGameFile(paths, "generalsv.exe"); + CreateGameFile(paths, "generalsonlinezh.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + IReadOnlyList clients = service.GetGameClients(); + + clients.Should().ContainSingle(); + clients[0].ExecutableName.Should().Be("generalsv.exe"); + clients[0].Kind.Should().Be(GameClientExecutableKind.Community); + } + + [Fact] + public void GetWorldBuildersReturnsVanillaThenCommunityWhenPresent() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + 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(WorldBuilderExecutableKind.Vanilla); + worldBuilders[0].IsAvailable.Should().BeTrue(); + worldBuilders[1].ExecutableName.Should().Be("worldbuilderzh.exe"); + worldBuilders[1].Kind.Should().Be(WorldBuilderExecutableKind.Community); + worldBuilders[1].IsAvailable.Should().BeTrue(); + } + + [Fact] + public void IsExecutableAvailableChecksRelativeNamesInGameDirectory() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + 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 IsExecutableAvailableRejectsRootedExecutablePaths() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string executablePath = Path.Combine(directory.Path, "external.exe"); + File.WriteAllText(executablePath, string.Empty); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + bool available = service.IsExecutableAvailable(executablePath); + + available.Should().BeFalse(); + } + + [Fact] + public void IsExecutableAvailableReturnsFalseForBlankExecutable() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + bool available = service.IsExecutableAvailable(" "); + + available.Should().BeFalse(); + } + + [Fact] + public void IsExecutableAvailableAcceptsRootLevelHardLink() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + 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 IsExecutableAvailableRejectsRootLevelSymbolicLink() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + 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 DiscoveryUsesNewActiveInstallationWithoutRebuildingService() + { + 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() + .Should().ContainSingle() + .Which.Should().Match(client => + client.ExecutableName == "generalsv.exe" && client.IsAvailable); + + runtimePaths.SwitchActive(zeroHourPaths); + + service.GetGameClients()[0] + .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); + } + + private static LauncherPaths CreatePaths(string root) + { + return TestLauncherPaths.Create(Path.Combine(root, "Game")); + } + +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameProcessLauncherTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameProcessLauncherTests.cs new file mode 100644 index 00000000..4d79a758 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameProcessLauncherTests.cs @@ -0,0 +1,172 @@ +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 GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class WindowsGameProcessLauncherTests +{ + [Fact] + public void GameLaunchRequestRejectsExternalOrNestedExecutablePaths() + { + 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 StartAsyncUsesExplicitGameExecutableAndArgumentsAsync() + { + 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)); + } + + [Fact] + public async Task StartAsyncUsesExplicitWorldBuilderExecutableAndArgumentsAsync() + { + 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 StartAsyncRejectsMissingExecutableAsync() + { + 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(); + } + + [SymbolicLinkFact] + public async Task StartAsyncRejectsExecutableSymbolicLinkAsync() + { + 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 + { + public TimeSpan RunningDuration { get; set; } = TimeSpan.FromSeconds(1); + + public List<(string ExecutableName, string Arguments, string WorkingDirectory)> Calls { get; } = new(); + + public Task StartAsync( + string executableName, + string arguments, + string workingDirectory, + CancellationToken cancellationToken) + { + Calls.Add((executableName, arguments, workingDirectory)); + return Task.FromResult( + new RecordingProcessFamilyLaunchOperation(executableName, RunningDuration)); + } + } + + private sealed class RecordingProcessFamilyLaunchOperation : IProcessFamilyLaunchOperation + { + public RecordingProcessFamilyLaunchOperation( + string executableName, + TimeSpan runningDuration) + { + CurrentExecutableName = executableName; + Completion = Task.FromResult(runningDuration); + } + + public string CurrentExecutableName { get; } + + public event EventHandler? CurrentExecutableNameChanged + { + add { } + remove { } + } + + public Task Completion { get; } + + public void ForceClose() + { + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsProcessFamilyLauncherTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsProcessFamilyLauncherTests.cs new file mode 100644 index 00000000..f6fcdb46 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsProcessFamilyLauncherTests.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +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 +{ + [Fact] + public async Task StartAsyncTracksDurationForShortLivedProcessAsync() + { + WindowsProcessFamilyLauncher launcher = new(NullLogger.Instance); + string executableName = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe"; + + IProcessFamilyLaunchOperation operation = await launcher.StartAsync( + executableName, + "/c exit 0", + Environment.CurrentDirectory, + CancellationToken.None); + TimeSpan duration = await operation.Completion; + + duration.Should().BeGreaterThanOrEqualTo(TimeSpan.Zero); + } + + [Fact] + public void ProcessFamilyTrackerTracksNestedDescendantsUntilGracePeriodExpires() + { + DateTime nowUtc = new(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10), (30, 20)), + Snapshot((30, 20)), + Snapshot(), + Snapshot(), + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + rootProcessId: 10, + captureProcessSnapshot: () => snapshots.Dequeue(), + getUtcNow: () => nowUtc); + + tracker.IsRunning().Should().BeTrue(); + nowUtc = nowUtc.AddSeconds(1); + tracker.IsRunning().Should().BeTrue(); + nowUtc = nowUtc.AddSeconds(1); + tracker.IsRunning().Should().BeTrue(); + nowUtc = nowUtc.AddSeconds(6); + tracker.IsRunning().Should().BeFalse(); + tracker.RunningDuration.Should().Be(TimeSpan.FromSeconds(1)); + } + + [Fact] + public void ProcessFamilyTrackerAllowsHandoffChildFromRecentlyRetiredParent() + { + DateTime nowUtc = new(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + Snapshot(), + Snapshot((30, 20)), + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + rootProcessId: 10, + captureProcessSnapshot: () => snapshots.Dequeue(), + getUtcNow: () => nowUtc); + + tracker.IsRunning().Should().BeTrue(); + nowUtc = nowUtc.AddSeconds(1); + tracker.IsRunning().Should().BeTrue(); + nowUtc = nowUtc.AddSeconds(1); + tracker.IsRunning().Should().BeTrue(); + } + + [Fact] + public void ProcessFamilyTrackerRejectsHandoffChildAfterParentRetirementExpires() + { + DateTime nowUtc = new(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + Snapshot(), + Snapshot((30, 20)), + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + rootProcessId: 10, + captureProcessSnapshot: () => snapshots.Dequeue(), + getUtcNow: () => nowUtc); + + tracker.IsRunning().Should().BeTrue(); + nowUtc = nowUtc.AddSeconds(1); + tracker.IsRunning().Should().BeTrue(); + nowUtc = nowUtc.AddSeconds(6); + tracker.IsRunning().Should().BeFalse(); + } + + [Fact] + public void ProcessFamilyTrackerStopsImmediatelyWhenRootExitsWithoutChildren() + { + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + rootProcessId: 10, + captureProcessSnapshot: () => Snapshot()); + + bool result = tracker.IsRunning(); + + result.Should().BeFalse(); + } + + [Fact] + public void ProcessFamilyTrackerFallsBackToRootProcessWhenSnapshotsFail() + { + DateTime nowUtc = new(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc); + Queue rootRunningStates = new(new[] { true, false }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + rootProcessId: 10, + captureProcessSnapshot: () => null, + isProcessRunning: _ => rootRunningStates.Dequeue(), + getUtcNow: () => nowUtc); + + tracker.IsRunning().Should().BeTrue(); + nowUtc = nowUtc.AddSeconds(3); + tracker.IsRunning().Should().BeFalse(); + tracker.RunningDuration.Should().Be(TimeSpan.Zero); + } + + [Fact] + public void ProcessFamilyTrackerForceCloseTargetsTrackedRunningFamily() + { + List forceClosedProcessIds = new(); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + rootProcessId: 10, + captureProcessSnapshot: () => 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 ProcessFamilyTrackerUpdatesCurrentExecutableToDeepestRunningDescendant() + { + 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( + rootProcessId: 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 ProcessFamilyTrackerStopsAfterChildHandoffExitsEvenWhenRootLauncherStillRuns() + { + DateTime nowUtc = new(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc); + 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( + rootProcessId: 10, + rootExecutableName: "generalsonlinezh.exe", + captureProcessSnapshot: () => snapshots.Dequeue(), + getUtcNow: () => nowUtc); + + tracker.IsRunning().Should().BeTrue(); + tracker.CurrentExecutableName.Should().Be("generalszh.exe"); + nowUtc = nowUtc.AddSeconds(1); + tracker.IsRunning().Should().BeTrue(); + tracker.CurrentExecutableName.Should().Be("generalszh.exe"); + nowUtc = nowUtc.AddSeconds(6); + tracker.IsRunning().Should().BeFalse(); + } + + private static WindowsProcessFamilyLauncher.ProcessFamilyTracker CreateTracker( + int rootProcessId, + Func?> captureProcessSnapshot, + string rootExecutableName = "", + Func? isProcessRunning = null, + Func? getUtcNow = null, + Action? forceCloseProcess = null) + { + return new WindowsProcessFamilyLauncher.ProcessFamilyTracker( + rootProcessId, + rootExecutableName, + NullLogger.Instance, + captureProcessSnapshot, + isProcessRunning ?? (_ => false), + getUtcNow ?? (() => new DateTime(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc)), + TimeSpan.FromSeconds(5), + forceCloseProcess ?? (_ => { })); + } + + private static IReadOnlyList Snapshot( + params (int ProcessId, int ParentProcessId)[] entries) + { + List snapshot = new(); + 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 = new(); + 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..c5b0e0ec --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentFilePlannerTests.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Infrastructure.Launching.Support; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Support; + +public sealed class DeploymentFilePlannerTests +{ + [Fact] + public void ResolveDeploymentFilesExcludesExecutableCodeFromDownloadedPackages() + { + using TestDirectory directory = new(); + string packageRoot = directory.CreateDirectory("Package"); + string dataDirectory = Directory.CreateDirectory(Path.Combine(packageRoot, "Data")).FullName; + File.WriteAllText(Path.Combine(dataDirectory, "payload.txt"), "data"); + 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, precedence: 0) }); + + result.Select(file => file.TargetRelativePath) + .Should() + .Equal("Data/payload.txt"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentPathResolverTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentPathResolverTests.cs new file mode 100644 index 00000000..489d4615 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentPathResolverTests.cs @@ -0,0 +1,130 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Support; +using GenLauncherGO.Tests.Testing; + +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 NormalizeManifestPathNormalizesSeparators(string relativePath, string expectedPath) + { + string result = DeploymentPathResolver.NormalizeManifestPath(relativePath); + + result.Should().Be(expectedPath); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void NormalizeManifestPathRejectsMissingPaths(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 NormalizeManifestPathRejectsUnsafePaths(string relativePath, string expectedMessage) + { + Action act = () => DeploymentPathResolver.NormalizeManifestPath(relativePath); + + act.Should().Throw() + .WithMessage(expectedMessage); + } + + [Fact] + public void ResolveGamePathReturnsPathInsideGameDirectory() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + + string result = DeploymentPathResolver.ResolveGamePath(paths, @"Data\GameData.ini"); + + result.Should().Be(Path.GetFullPath(Path.Combine(paths.GameDirectory, "Data", "GameData.ini"))); + } + + [Fact] + public void ResolveGamePathRejectsLauncherOwnedPaths() + { + 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 ToRelativeManifestPathReturnsNormalizedChildPath() + { + 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 ToRelativeManifestPathRejectsPathsOutsideRoot() + { + 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 ResolveDeploymentStatePathReturnsPathInsideDeploymentDirectory() + { + 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 ResolveDeploymentStatePathRejectsPathsOutsideDeploymentDirectory() + { + using TestDirectory directory = new(); + string deploymentDirectory = Path.Combine(directory.Path, "Deployment"); + + Action act = () => DeploymentPathResolver.ResolveDeploymentStatePath( + deploymentDirectory, + "../manifest.yaml"); + + act.Should().Throw(); + } + + private static LauncherPaths CreatePaths(TestDirectory directory) + { + return TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Support/WindowsHardLinkCreatorTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Support/WindowsHardLinkCreatorTests.cs new file mode 100644 index 00000000..120b3419 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Support/WindowsHardLinkCreatorTests.cs @@ -0,0 +1,45 @@ +using System.IO; +using GenLauncherGO.Infrastructure.Launching.Support; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Support; + +public sealed class WindowsHardLinkCreatorTests +{ + [Fact] + public void TryCreateHardLinkCreatesNonReparseHardLinkToExistingFile() + { + 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 TryCreateHardLinkReturnsFalseWhenSourceIsMissing() + { + 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(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/LoggingServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/Infrastructure/LoggingServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..02b262fe --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/LoggingServiceCollectionExtensionsTests.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using GenLauncherGO.Infrastructure.Logging; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Tests.Infrastructure; + +public sealed class LoggingServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoLoggingCreatesReadableSessionLog() + { + 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 => + Regex.IsMatch( + Path.GetFileName(file), + @"^GenLauncherGO-\d{4}-\d{2}-\d{2}-\d{6}Z(-\d+)?\.log$")); + } + + [Fact] + public void AddGenLauncherGoLoggingPrunesOldSessionLogs() + { + using TestDirectory directory = new(); + string logDirectory = directory.CreateDirectory("Logs"); + for (int index = 0; index < 20; index++) + { + string logFilePath = Path.Combine( + logDirectory, + $"GenLauncherGO-2026-01-{index + 1:00}-120000Z.log"); + File.WriteAllText(logFilePath, "old"); + File.SetLastWriteTimeUtc(logFilePath, DateTime.UtcNow.AddMinutes(-index - 1)); + } + + var services = new ServiceCollection(); + + services.AddGenLauncherGoLogging(logDirectory); + using (ServiceProvider provider = services.BuildServiceProvider()) + { + provider + .GetRequiredService>() + .LogInformation("Current session"); + } + + Directory.GetFiles(logDirectory, "*.log").Should().HaveCountLessThanOrEqualTo(14); + File.Exists(Path.Combine(logDirectory, "GenLauncherGO-2026-01-20-120000Z.log")).Should().BeFalse(); + } + + [Fact] + public void AddGenLauncherGoLoggingRedactsLocalPathsAndSensitiveQueryValues() + { + 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 AddGenLauncherGoLoggingRedactsUncAndForwardSlashWindowsPaths() + { + 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"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs new file mode 100644 index 00000000..1d04efcc --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class FileSystemLocalLauncherContentServiceTests +{ + [Fact] + public void FindInstalledVersionsReturnsInstalledModsPatchesAndAddons() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(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"); + } + + [Fact] + public void DeleteVersionDeletesVersionAndPrunesEmptyParents() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string versionDirectory = Path.Combine(paths.ModsDirectory, "ShockWave", "Addons", "HD", "1.0"); + CreateFile(Path.Combine(versionDirectory, "HD.big")); + var version = new 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 DeleteVersionDeletesPackageStagingFolderWhenInstalledFolderIsMissing() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string versionDirectory = Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"); + string packageStagingDirectory = paths.GetPackageTemporaryPath( + new OwnedContentPath(paths.ModsDirectory, versionDirectory)).FullPath; + CreateFile(Path.Combine(packageStagingDirectory, "Data", "INI.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(versionDirectory).Should().BeFalse(); + } + + [Fact] + public void DeleteVersionDeletesPackageStagingFolderForChildContentWhenInstalledFolderIsMissing() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string versionDirectory = Path.Combine(paths.ModsDirectory, "ShockWave", "Addons", "HD", "1.0"); + string packageStagingDirectory = paths.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 DeleteContentDeletesModRootAndPackageStagingRoot() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string contentDirectory = Path.Combine(paths.ModsDirectory, "ShockWave"); + string packageStagingDirectory = paths.GetPackageTemporaryPath( + new OwnedContentPath(paths.ModsDirectory, contentDirectory)).FullPath; + 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")); + 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(paths.ModsDirectory).Should().BeTrue(); + } + + [Fact] + public void DeleteContentDeletesChildContentRoot() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(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 DeleteVersionRefusesPathOutsideModsRoot() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(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 DeleteImagesIfUnusedDeletesVersionImagesWhenNoCardReferencesContentName() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + string cardImage = Path.Combine(imageDirectory, "1.2.png"); + string backgroundImage = Path.Combine(imageDirectory, "1.2-background.jpg"); + string otherImage = Path.Combine(imageDirectory, "readme.txt"); + CreateFile(cardImage); + CreateFile(backgroundImage); + CreateFile(otherImage); + var version = new 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(otherImage).Should().BeTrue(); + Directory.Exists(imageDirectory).Should().BeTrue(); + } + + [Fact] + public void DeleteImagesIfUnusedKeepsImagesWhenCardStillReferencesContentName() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string imagePath = Path.Combine(paths.GetModificationImagesDirectory("ShockWave"), "1.2.png"); + CreateFile(imagePath); + var version = new 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 LauncherPaths CreatePaths(string root) + { + return TestLauncherPaths.Create(root); + } + + 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..4a86b5df --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemManualModificationImporterTests.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Archives.Contracts; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class FileSystemManualModificationImporterTests +{ + [Fact] + public void ImportCopiesRegularFilesToDestination() + { + 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)); + + File.ReadAllText(Path.Combine(destinationDirectory, "readme.txt")) + .Should().Be("manual content"); + File.Exists(sourceFilePath).Should().BeTrue(); + } + + [Fact] + public void ImportRenamesLooseBigFilesToGibFiles() + { + 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)); + + File.Exists(Path.Combine(destinationDirectory, "package.big")).Should().BeFalse(); + File.ReadAllText(Path.Combine(destinationDirectory, "package.gib")) + .Should().Be("big content"); + } + + [Fact] + public void ImportCopiesLooseGibFilesToDestination() + { + 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"); + RecordingLogger logger = new(); + + FileSystemManualModificationImporter importer = CreateImporter(logger: logger); + + importer.Import( + new[] { sourceFilePath }, + CreateOwnedDestination(directory.Path, destinationDirectory)); + + File.ReadAllText(Path.Combine(destinationDirectory, "package.gib")) + .Should().Be("gib content"); + File.Exists(sourceFilePath).Should().BeTrue(); + logger.Entries.Should().Contain(entry => + entry.LogLevel == LogLevel.Information && + entry.Message.Contains("Imported 1 manual content file(s)", StringComparison.Ordinal)); + } + + [Theory] + [InlineData(".zip")] + [InlineData(".rar")] + [InlineData(".7z")] + public void ImportExtractsArchivesAndDeletesStagedArchive(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(); + FileSystemManualModificationImporter importer = CreateImporter(archiveExtractor); + + importer.Import( + new[] { sourceFilePath }, + CreateOwnedDestination(directory.Path, destinationDirectory)); + + 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(); + } + + [Fact] + public void ImportRejectsEmptySourceFileList() + { + 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*"); + } + + [Fact] + public void ImportRequestRejectsDestinationOutsideOwnershipBoundaryBeforeMutation() + { + 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(); + } + + [SymbolicLinkFact] + public void ImportRejectsReparsePointsInDestinationBeforeArchiveExtraction() + { + 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"); + SymbolicLinkTestSupport.CreateDirectoryLink( + 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 ImportLogsFailuresBeforeRethrowing() + { + 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 && + entry.Message.Contains("Failed to import manual content", StringComparison.Ordinal)); + } + + 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); + } + + private sealed class RecordingArchiveExtractor : IArchiveExtractor + { + public string? ArchiveFilePath { get; private set; } + + public string? DestinationDirectory { get; private set; } + + public void ExtractToDirectory( + string archiveFilePath, + string destinationDirectory, + bool convertBigFilesToGib = false, + CancellationToken cancellationToken = default) + { + ArchiveFilePath = archiveFilePath; + DestinationDirectory = destinationDirectory; + File.WriteAllText(Path.Combine(destinationDirectory, "extracted.txt"), "extracted content"); + } + } + + private sealed class RecordingLogger : ILogger + { + public List Entries { get; } = new(); + + 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 LogEntry(logLevel, formatter(state, exception), exception)); + } + } + + private sealed record LogEntry( + LogLevel LogLevel, + string Message, + Exception? Exception); + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs new file mode 100644 index 00000000..770ae1ee --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs @@ -0,0 +1,347 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class FileSystemModificationImageFileServiceTests +{ + [Fact] + public void FindExistingImageFilePathReturnsFirstMatchingImage() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string imagePath = Path.Combine(imageDirectory, "1.2.jpg"); + File.WriteAllText(imagePath, "image"); + FileSystemModificationImageFileService service = CreateService(paths); + + string? existingImagePath = service.FindExistingImageFilePath("ShockWave", "1.2"); + + existingImagePath.Should().Be(imagePath); + } + + [Fact] + public void FindExistingImageFilePathReturnsNullForMissingDirectory() + { + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + string? existingImagePath = service.FindExistingImageFilePath("Missing", "1.2"); + + existingImagePath.Should().BeNull(); + } + + [Fact] + public void CountImageFilesReturnsZeroForMissingDirectory() + { + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + int count = service.CountImageFiles("Missing"); + + count.Should().Be(0); + } + + [Fact] + public void CountImageFilesReturnsImageFileCount() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(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("ShockWave"); + + count.Should().Be(2); + } + + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData(" ", false)] + public void ImageExistsReturnsFalseForMissingPathValues(string? imagePath, bool expected) + { + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + bool exists = service.ImageExists(imagePath); + + exists.Should().Be(expected); + } + + [Fact] + public void ImageExistsReturnsTrueForExistingFile() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(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 ImageExistsReturnsFalseForExistingFileOutsideActiveCache() + { + using TestDirectory directory = new(); + string imagePath = Path.Combine(directory.Path, "image.png"); + File.WriteAllText(imagePath, "image"); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + bool exists = service.ImageExists(imagePath); + + exists.Should().BeFalse(); + File.Exists(imagePath).Should().BeTrue(); + } + + [Fact] + public void TryDeleteImageRemovesMatchingCachedImages() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(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("ShockWave", "1.2"); + + deleted.Should().BeTrue(); + File.Exists(pngImagePath).Should().BeFalse(); + File.Exists(jpgImagePath).Should().BeFalse(); + } + + [Fact] + public void TryDeleteImageRejectsUnsafeCacheIdentity() + { + using TestDirectory directory = new(); + string outsideImagePath = directory.CreateFile("victim.png", "outside"); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + bool deleted = service.TryDeleteImage("..", "victim"); + + deleted.Should().BeFalse(); + File.ReadAllText(outsideImagePath).Should().Be("outside"); + } + + [Fact] + public void TryDeleteImageReturnsTrueWhenFileDoesNotExist() + { + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + bool deleted = service.TryDeleteImage("ShockWave", "missing"); + + deleted.Should().BeTrue(); + } + + [SymbolicLinkFact] + public void FindExistingImageFilePathRejectsLinkedImageDirectory() + { + 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"); + SymbolicLinkTestSupport.CreateDirectoryLink( + paths.GetModificationImagesDirectory("ShockWave"), + outsideDirectory); + FileSystemModificationImageFileService service = CreateService(paths); + + Action act = () => service.FindExistingImageFilePath("ShockWave", "1.2"); + + act.Should().Throw(); + File.ReadAllText(outsideImagePath).Should().Be("outside"); + } + + [SymbolicLinkFact] + public void TryDeleteImageDoesNotFollowLinkedImageDirectory() + { + 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"); + SymbolicLinkTestSupport.CreateDirectoryLink( + paths.GetModificationImagesDirectory("ShockWave"), + outsideDirectory); + FileSystemModificationImageFileService service = CreateService(paths); + + bool deleted = service.TryDeleteImage("ShockWave", "1.2"); + + deleted.Should().BeFalse(); + File.ReadAllText(outsideImagePath).Should().Be("outside"); + } + + [SymbolicLinkFact] + public async Task ReplaceImageAsyncDoesNotFollowLinkedImageDirectoryAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string outsideDirectory = directory.CreateDirectory("OutsideImages"); + string outsideImagePath = Path.Combine(outsideDirectory, "1.2.jpg"); + File.WriteAllText(outsideImagePath, "outside"); + SymbolicLinkTestSupport.CreateDirectoryLink( + 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(); + File.ReadAllText(outsideImagePath).Should().Be("outside"); + File.Exists(Path.Combine(outsideDirectory, "1.2.png")).Should().BeFalse(); + } + + [Fact] + public async Task ReplaceImageAsyncDeletesStaleExtensionsAndCopiesSelectedImageAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string staleImagePath = Path.Combine(imageDirectory, "1.2.jpg"); + File.WriteAllText(staleImagePath, "old"); + string sourceImagePath = Path.Combine(directory.Path, "selected.png"); + File.WriteAllText(sourceImagePath, "new"); + FileSystemModificationImageFileService service = CreateService(paths); + + 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(); + File.ReadAllText(destinationPath).Should().Be("new"); + } + + [Fact] + public async Task ReplaceImageAsyncNoOpsWhenSourceAlreadyIsDestinationAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string existingImagePath = Path.Combine(imageDirectory, "1.2.png"); + File.WriteAllText(existingImagePath, "same"); + FileSystemModificationImageFileService service = CreateService(paths); + + string destinationPath = await service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", existingImagePath), + CancellationToken.None); + + destinationPath.Should().Be(existingImagePath); + File.ReadAllText(existingImagePath).Should().Be("same"); + } + + [Fact] + public async Task ReplaceImageAsyncThrowsForSourceWithoutExtensionAsync() + { + using TestDirectory directory = new(); + string sourceImagePath = Path.Combine(directory.Path, "selected"); + File.WriteAllText(sourceImagePath, "new"); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + Func act = () => service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReplaceImageAsyncThrowsIOExceptionWhenSourceCannotBeCopiedAsync() + { + using TestDirectory directory = new(); + string sourceImagePath = Path.Combine(directory.Path, "missing.png"); + FileSystemModificationImageFileService service = CreateService(CreatePaths(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 ReplaceImageAsyncHonorsPreCanceledTokenAsync() + { + using TestDirectory directory = new(); + string sourceImagePath = Path.Combine(directory.Path, "selected.png"); + File.WriteAllText(sourceImagePath, "new"); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + Func act = () => service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + cancellation.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReplaceImageAsyncUsesNewGameCacheWithoutRebuildingServiceAsync() + { + 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 service = new FileSystemModificationImageFileService( + runtimePaths, + NullLogger.Instance); + string sourceImagePath = Path.Combine(directory.Path, "selected.png"); + File.WriteAllText(sourceImagePath, "image"); + var request = new ModificationImageReplacementRequest("Shared Mod", "1.0", sourceImagePath); + + string generalsImage = await service.ReplaceImageAsync(request, CancellationToken.None); + runtimePaths.SwitchActive(zeroHourPaths); + string zeroHourImage = 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(); + } + + private static FileSystemModificationImageFileService CreateService(LauncherPaths paths) + { + return new FileSystemModificationImageFileService( + TestLauncherPaths.CreateRuntimePathContext(paths), + NullLogger.Instance); + } + + private static LauncherPaths CreatePaths(string root) + { + return TestLauncherPaths.Create(root); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs new file mode 100644 index 00000000..69449c86 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs @@ -0,0 +1,285 @@ +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 GenLauncherGO.Tests.Testing; +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")] + public async Task CacheModificationImagesAsyncDownloadsCardImageToExpectedPathAsync( + string cardLink, + string expectedImageFileName) + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + 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 CacheModificationImagesAsyncSkipsEmptyImageLinksAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + var modification = new LauncherContentVersion + { + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = string.Empty, + }; + + await cache.CacheModificationImagesAsync(modification, paths, CancellationToken.None); + + assetDownloader.Calls.Should().BeEmpty(); + } + + [Fact] + public async Task CacheModificationImagesAsyncContinuesWhenCardImageDownloadFailsAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + 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 CacheModificationImagesAsyncRethrowsCancellationAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + 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(); + } + + [SymbolicLinkFact] + public async Task CacheModificationImagesAsyncDoesNotDownloadThroughLinkedImageDirectoryAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string outsideDirectory = directory.CreateDirectory("OutsideImages"); + SymbolicLinkTestSupport.CreateDirectoryLink( + paths.GetModificationImagesDirectory("ShockWave"), + outsideDirectory); + var assetDownloader = new RecordingRemoteAssetDownloader(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + 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 CacheAdvertisingImagesAsyncDeletesStaleImagesWhenImageCountChangesAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + Directory.CreateDirectory(paths.GetModificationImagesDirectory("Featured Mod")); + string staleImagePath = paths.GetModificationImageFilePath("Featured Mod", "old.png"); + await File.WriteAllTextAsync(staleImagePath, "stale"); + var assetDownloader = new RecordingRemoteAssetDownloader(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + var advertisingData = new RemoteAdvertisingReference( + "Featured Mod", + "https://example.test/featured.yaml", + new List + { + "https://cdn.example.test/0.jpg", + "https://cdn.example.test/1.jpg" + }); + + 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 CacheAdvertisingImagesAsyncContinuesWhenStaleImageCannotBeDeletedAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + Directory.CreateDirectory(paths.GetModificationImagesDirectory("Featured Mod")); + string staleImagePath = paths.GetModificationImageFilePath("Featured Mod", "old.png"); + await File.WriteAllTextAsync(staleImagePath, "stale"); + await using FileStream lockedImage = File.Open( + staleImagePath, + FileMode.Open, + FileAccess.Read, + FileShare.None); + var assetDownloader = new RecordingRemoteAssetDownloader(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + var advertisingData = new RemoteAdvertisingReference( + "Featured Mod", + "https://example.test/featured.yaml", + new List + { + "https://cdn.example.test/0.jpg", + "https://cdn.example.test/1.jpg" + }); + + 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 CacheAdvertisingImagesAsyncKeepsExistingImagesWhenImageCountMatchesAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(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"); + await File.WriteAllTextAsync(secondExistingImagePath, "existing"); + var assetDownloader = new RecordingRemoteAssetDownloader(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + var advertisingData = new RemoteAdvertisingReference( + "Featured Mod", + "https://example.test/featured.yaml", + new List + { + "https://cdn.example.test/0.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")); + } + + [SymbolicLinkFact] + public async Task CacheAdvertisingImagesAsyncDoesNotMutateThroughLinkedImageDirectoryAsync() + { + 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"); + await File.WriteAllTextAsync(secondOutsideImage, "second"); + SymbolicLinkTestSupport.CreateDirectoryLink( + paths.GetModificationImagesDirectory("Featured Mod"), + outsideDirectory); + var assetDownloader = new RecordingRemoteAssetDownloader(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + var advertisingData = new RemoteAdvertisingReference( + "Featured Mod", + "https://example.test/featured.yaml", + new List + { + "https://cdn.example.test/0.jpg", + }); + + await cache.CacheAdvertisingImagesAsync(advertisingData, paths, CancellationToken.None); + + assetDownloader.Calls.Should().BeEmpty(); + File.ReadAllText(firstOutsideImage).Should().Be("first"); + File.ReadAllText(secondOutsideImage).Should().Be("second"); + } + + private static LauncherPaths CreatePaths(string root) + { + return TestLauncherPaths.Create(root); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs new file mode 100644 index 00000000..8c694e17 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs @@ -0,0 +1,1415 @@ +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.Exceptions; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Infrastructure.Remote.Contracts; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherContentCatalogServiceTests +{ + [Fact] + public async Task InitDataAsyncWithDisconnectedCatalogLoadsOnlyLocalStateAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + var yamlReader = new StubRemoteYamlDocumentReader(); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + stateStore.StateToLoad = new LauncherContentState + { + Modifications = new List + { + new LauncherContentEntryState + { + Name = "ShockWave", + ModificationVersions = new List + { + new LauncherContentVersionState + { + Name = "ShockWave", + Version = "1.0", + Installed = true + } + } + } + } + }; + localContentService.InstalledVersions = new List + { + new LauncherContentVersion + { + Installation = new LauncherContentInstallation { Installed = true }, + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + } + }; + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, paths), + CancellationToken.None); + await service.ReadPatchesAndAddonsForModAsync(LauncherContentKey.ForModificationName("ShockWave"), CancellationToken.None); + + service.Data.Modifications.Select(modification => modification.Name).Should().Equal("ShockWave"); + service.RepositoryModificationNames.Should().BeNull(); + yamlReader.GetReadCount().Should().Be(0); + } + + [Fact] + public async Task InitDataAsyncSwitchesGameNamespaceAndClearsPreviouslyCachedContentAsync() + { + 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")); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + new StubRemoteYamlDocumentReader(), + new RecordingRemoteAssetDownloader()); + stateStore.StatesToLoadByGame[SupportedGame.Generals] = + CreateSingleInstalledModificationState("Shared Mod", "Generals Version"); + stateStore.StatesToLoadByGame[SupportedGame.ZeroHour] = + CreateSingleInstalledModificationState("Shared Mod", "Zero Hour Version"); + localContentService.InstalledVersions = new List + { + CreateInstalledModificationVersion("Shared Mod", "Generals Version"), + }; + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, generalsPaths), + CancellationToken.None); + service.Data.Modifications.Should().ContainSingle() + .Which.Versions.Should().ContainSingle() + .Which.Version.Should().Be("Generals Version"); + + localContentService.InstalledVersions = new List + { + CreateInstalledModificationVersion("Shared Mod", "Zero Hour Version"), + }; + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, zeroHourPaths), + CancellationToken.None); + + service.Data.Modifications.Should().ContainSingle() + .Which.Versions.Should().ContainSingle() + .Which.Version.Should().Be("Zero Hour Version"); + stateStore.LoadedPaths.Should().Equal(generalsPaths, zeroHourPaths); + } + + [Fact] + public async Task InitDataAsyncRestoresPreviousGameCatalogWhenSwitchInitializationFailsAsync() + { + 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")); + var manifestUri = new Uri("https://example.test/unavailable.yaml"); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService + { + InstalledVersions = new List + { + CreateInstalledModificationVersion("Shared Mod", "Generals Version"), + }, + }; + var yamlReader = new StubRemoteYamlDocumentReader(); + yamlReader.SetException( + manifestUri, + new IOException("Catalog unavailable.")); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + new RecordingRemoteAssetDownloader()); + stateStore.StatesToLoadByGame[SupportedGame.Generals] = + CreateSingleInstalledModificationState("Shared Mod", "Generals Version"); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, generalsPaths), + CancellationToken.None); + Func switchGame = () => service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, zeroHourPaths), + CancellationToken.None); + + await switchGame.Should().ThrowAsync(); + service.Data.Modifications.Should().ContainSingle() + .Which.Versions.Should().ContainSingle() + .Which.Version.Should().Be("Generals Version"); + + service.SaveLauncherData(); + stateStore.SavedPaths.Should().ContainSingle().Which.Should().Be(generalsPaths); + } + + [Fact] + public async Task InitDataAsyncReadsRemoteCatalogForInstalledModsAndDownloadsImagesAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/shockwave.yaml"); + var cardImageUri = new Uri("https://cdn.example.test/shockwave.jpg"); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + var yamlReader = new StubRemoteYamlDocumentReader(); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + + stateStore.StateToLoad = new LauncherContentState + { + Modifications = new List + { + new LauncherContentEntryState + { + Name = "ShockWave", + Installed = true, + ModificationVersions = new List + { + new LauncherContentVersionState + { + Name = "ShockWave", + Version = "1.0", + Installed = true + } + } + } + } + }; + localContentService.InstalledVersions = new List + { + new LauncherContentVersion + { + Installation = new LauncherContentInstallation { Installed = true }, + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + } + }; + yamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = new List + { + new LegacyCatalogModificationReference + { + ModName = "ShockWave", + ModLink = modUri.ToString() + } + } + }); + yamlReader.SetResult(modUri, new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = cardImageUri.ToString(), + }); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, paths), + CancellationToken.None); + + service.RepositoryModificationNames.Should().Equal("ShockWave"); + LauncherContent mod = service.Data.Modifications + .Should() + .ContainSingle(item => item.Name == "ShockWave") + .Subject; + mod.Versions.Should().Contain(version => version.Version == "1.2"); + assetDownloader.Calls.Should().Equal( + (cardImageUri, paths.GetModificationImageFilePath("ShockWave", "1.2.jpg"))); + } + + [Fact] + public async Task InitDataAsyncLoadsSelectedModPatchesAndAddonsAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/shockwave.yaml"); + var patchUri = new Uri("https://example.test/patch.yaml"); + var addonUri = new Uri("https://example.test/addon.yaml"); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + var yamlReader = new StubRemoteYamlDocumentReader(); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + + stateStore.StateToLoad = new LauncherContentState + { + Modifications = new List + { + new LauncherContentEntryState + { + Name = "ShockWave", + IsSelected = true, + ModificationVersions = new List + { + new LauncherContentVersionState + { + Name = "ShockWave", + Version = "1.0", + Installed = true, + IsSelected = true + } + } + } + } + }; + localContentService.InstalledVersions = new List + { + new LauncherContentVersion + { + Installation = new LauncherContentInstallation { Installed = true }, + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + } + }; + yamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = new List + { + new LegacyCatalogModificationReference + { + ModName = "ShockWave", + ModLink = modUri.ToString(), + ModPatches = new List { patchUri.ToString() }, + ModAddons = new List { addonUri.ToString() } + } + } + }); + yamlReader.SetResult( + modUri, + CreateRemoteVersion("ShockWave", "1.2", ModificationType.Mod)); + yamlReader.SetResult( + patchUri, + CreateRemoteVersion("Balance", "2.0", ModificationType.Patch, "ShockWave")); + yamlReader.SetResult( + addonUri, + CreateRemoteVersion("HD", "1.0", ModificationType.Addon, "ShockWave")); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, paths), + CancellationToken.None); + await service.ReadPatchesAndAddonsForModAsync(LauncherContentKey.ForModificationName("ShockWave"), CancellationToken.None); + + service.Data.Patches.SelectMany(patch => patch.Versions).Should() + .ContainSingle(version => version.Name == "Balance" && version.Version == "2.0"); + service.Data.Addons.SelectMany(addon => addon.Versions).Should() + .ContainSingle(version => version.Name == "HD" && version.Version == "1.0"); + yamlReader.GetReadCount(patchUri).Should().Be(1); + yamlReader.GetReadCount(addonUri).Should().Be(1); + } + + [Fact] + public async Task ReadPatchesAndAddonsForModAsyncRetriesAfterPartialLoadFailureAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/shockwave.yaml"); + var patchUri = new Uri("https://example.test/patch.yaml"); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + var yamlReader = new StubRemoteYamlDocumentReader(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + new RecordingRemoteAssetDownloader()); + + stateStore.StateToLoad = new LauncherContentState(); + localContentService.InstalledVersions = Array.Empty(); + yamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + { + new LegacyCatalogModificationReference + { + ModName = "ShockWave", + ModLink = modUri.ToString(), + ModPatches = { patchUri.ToString() } + } + } + }); + yamlReader.SetResult( + modUri, + CreateRemoteVersion("ShockWave", "1.2", ModificationType.Mod)); + yamlReader.SetHandler( + patchUri, + (callIndex, _) => callIndex == 1 + ? Task.FromException(new IOException("Temporary failure.")) + : Task.FromResult(CreateRemoteVersion( + "Balance", + "2.0", + ModificationType.Patch, + "ShockWave"))); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, paths), + CancellationToken.None); + await service.AddRepositoryModificationAsync("ShockWave", CancellationToken.None); + var modification = LauncherContentKey.ForModificationName("ShockWave"); + + await service.ReadPatchesAndAddonsForModAsync(modification, CancellationToken.None); + await service.ReadPatchesAndAddonsForModAsync(modification, CancellationToken.None); + await service.ReadPatchesAndAddonsForModAsync(modification, CancellationToken.None); + + service.Data.Patches.SelectMany(patch => patch.Versions).Should() + .ContainSingle(version => version.Name == "Balance" && version.Version == "2.0"); + yamlReader.GetReadCount(patchUri).Should().Be(2); + } + + [Fact] + public async Task ReadPatchesAndAddonsForModAsyncCoalescesConcurrentLoadsAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/shockwave.yaml"); + var patchUri = new Uri("https://example.test/patch.yaml"); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + var yamlReader = new StubRemoteYamlDocumentReader(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + new RecordingRemoteAssetDownloader()); + var patchReadStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releasePatchRead = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + stateStore.StateToLoad = new LauncherContentState(); + localContentService.InstalledVersions = Array.Empty(); + yamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + { + new LegacyCatalogModificationReference + { + ModName = "ShockWave", + ModLink = modUri.ToString(), + ModPatches = { patchUri.ToString() } + } + } + }); + yamlReader.SetResult( + modUri, + CreateRemoteVersion("ShockWave", "1.2", ModificationType.Mod)); + yamlReader.SetHandler( + patchUri, + (_, _) => + { + patchReadStarted.TrySetResult(true); + return releasePatchRead.Task; + }); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, paths), + CancellationToken.None); + await service.AddRepositoryModificationAsync("ShockWave", CancellationToken.None); + var modification = LauncherContentKey.ForModificationName("ShockWave"); + + Task firstLoad = service.ReadPatchesAndAddonsForModAsync(modification, CancellationToken.None); + await patchReadStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Task secondLoad = service.ReadPatchesAndAddonsForModAsync(modification, CancellationToken.None); + releasePatchRead.SetResult( + CreateRemoteVersion("Balance", "2.0", ModificationType.Patch, "ShockWave")); + await Task.WhenAll(firstLoad, secondLoad); + + service.Data.Patches.SelectMany(patch => patch.Versions).Should() + .ContainSingle(version => version.Name == "Balance" && version.Version == "2.0"); + yamlReader.GetReadCount(patchUri).Should().Be(1); + } + + [Fact] + public async Task ReadOriginalGameAddonsAndPatchesAsyncLoadsChildContentOnceAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + 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"); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + var yamlReader = new StubRemoteYamlDocumentReader(); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + + stateStore.StateToLoad = new LauncherContentState(); + localContentService.InstalledVersions = Array.Empty(); + yamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + originalGamePatches = new List { patchUri.ToString() }, + originalGameAddons = new List { addonUri.ToString() } + }); + yamlReader.SetResult( + patchUri, + CreateRemoteVersion("GenPatcher", "1.0", ModificationType.Patch)); + yamlReader.SetResult( + addonUri, + CreateRemoteVersion("ControlBar", "1.0", ModificationType.Addon)); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, paths), + CancellationToken.None); + await service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + await service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + service.UpdateLocalModificationsData(); + + service.Data.Patches.SelectMany(patch => patch.Versions).Should() + .ContainSingle(version => version.Name == "GenPatcher"); + service.Data.Addons.SelectMany(addon => addon.Versions).Should() + .ContainSingle(version => version.Name == "ControlBar"); + yamlReader.GetReadCount(patchUri).Should().Be(1); + yamlReader.GetReadCount(addonUri).Should().Be(1); + } + + [Fact] + public async Task ReadOriginalGameAddonsAndPatchesAsyncRetriesAfterPartialLoadFailureAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var patchUri = new Uri("https://example.test/original-patch.yaml"); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + var yamlReader = new StubRemoteYamlDocumentReader(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + new RecordingRemoteAssetDownloader()); + + stateStore.StateToLoad = new LauncherContentState(); + localContentService.InstalledVersions = Array.Empty(); + yamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + originalGamePatches = { patchUri.ToString() } + }); + yamlReader.SetHandler( + patchUri, + (callIndex, _) => callIndex == 1 + ? Task.FromException(new IOException("Temporary failure.")) + : Task.FromResult(CreateRemoteVersion("GenPatcher", "1.0", ModificationType.Patch))); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, paths), + CancellationToken.None); + + await service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + await service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + await service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + + service.Data.Patches.SelectMany(patch => patch.Versions).Should() + .ContainSingle(version => version.Name == "GenPatcher"); + yamlReader.GetReadCount(patchUri).Should().Be(2); + } + + [Fact] + public async Task ReadOriginalGameAddonsAndPatchesAsyncReturnsWhenCatalogIsDisconnectedAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + var yamlReader = new StubRemoteYamlDocumentReader(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + new RecordingRemoteAssetDownloader()); + + stateStore.StateToLoad = new LauncherContentState(); + localContentService.InstalledVersions = Array.Empty(); + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, paths), + CancellationToken.None); + + await service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + + yamlReader.GetReadCount().Should().Be(0); + } + + [Fact] + public async Task ReadPatchesAndAddonsForModAsyncReturnsWhenManifestLookupIsMissingAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + var yamlReader = new StubRemoteYamlDocumentReader(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + new RecordingRemoteAssetDownloader()); + + stateStore.StateToLoad = new LauncherContentState(); + localContentService.InstalledVersions = Array.Empty(); + yamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument()); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, paths), + CancellationToken.None); + + await service.ReadPatchesAndAddonsForModAsync(LauncherContentKey.ForModificationName("Missing"), CancellationToken.None); + + yamlReader.GetReadCount().Should().Be(0); + } + + [Fact] + public async Task InitDataAsyncDownloadsAdvertisingMetadataAndImagesAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + 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"); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + var yamlReader = new StubRemoteYamlDocumentReader(); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + + stateStore.StateToLoad = new LauncherContentState(); + localContentService.InstalledVersions = Array.Empty(); + yamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + AdvData = new List + { + new LegacyCatalogAdvertisingReference + { + ModName = "RiseOfTheReds", + ModLink = advertisingUri.ToString(), + ImagesData = new List { imageUri.ToString() } + } + } + }); + yamlReader.SetResult(advertisingUri, new LegacyContentManifest + { + ModificationType = ModificationType.Advertising, + Name = "RiseOfTheReds", + Version = "1.87" + }); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, paths), + CancellationToken.None); + + LauncherContentVersion? advertising = service.Advertising; + advertising.Should().NotBeNull(); + advertising!.Name.Should().Be("RiseOfTheReds"); + advertising.Version.Should().Be("1.87"); + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == imageUri && + call.DestinationFilePath == + paths.GetModificationImageFilePath("RiseOfTheReds", "0.jpg")); + } + + [Fact] + public async Task InitDataAsyncLeavesAdvertisingEmptyWhenManifestDownloadFailsAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var advertisingUri = new Uri("https://example.test/advertising.yaml"); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + var yamlReader = new StubRemoteYamlDocumentReader(); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + + stateStore.StateToLoad = new LauncherContentState(); + localContentService.InstalledVersions = Array.Empty(); + yamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + AdvData = new List + { + new LegacyCatalogAdvertisingReference + { + ModName = "RiseOfTheReds", + ModLink = advertisingUri.ToString(), + ImagesData = new List { "https://cdn.example.test/advertising.jpg" } + } + } + }); + yamlReader.SetException( + advertisingUri, + new IOException("Manifest unavailable.")); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, paths), + CancellationToken.None); + + service.Advertising.Should().BeNull(); + assetDownloader.Calls.Should().BeEmpty(); + } + + [Fact] + public async Task PersistedSelectionStateIsLoadedAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + new StubRemoteYamlDocumentReader(), + new RecordingRemoteAssetDownloader()); + + LauncherContentState catalogState = new() + { + Modifications = new List + { + CreateEntry( + ModificationType.Mod, + "ShockWave", + parentContentName: string.Empty, + isSelected: true, + version: "1.2", + versionSelected: true), + CreateEntry( + ModificationType.Mod, + "Contra", + parentContentName: string.Empty, + isSelected: false, + version: "009", + versionSelected: false) + }, + Patches = new List + { + CreateEntry( + ModificationType.Patch, + "BalancePatch", + parentContentName: "ShockWave", + isSelected: true, + version: "2.0", + versionSelected: true) + }, + Addons = new List + { + CreateEntry( + ModificationType.Addon, + "HDTextures", + parentContentName: "ShockWave", + isSelected: true, + version: "1.0", + versionSelected: true), + CreateEntry( + ModificationType.Addon, + "PatchAddon", + parentContentName: "BalancePatch", + isSelected: true, + version: "1.1", + versionSelected: true) + } + }; + stateStore.StateToLoad = catalogState; + localContentService.InstalledVersions = GetVersions(catalogState); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, paths), + CancellationToken.None); + + LauncherContent selectedModification = service.Data.Modifications.Single(modification => + modification.IsSelected); + LauncherContent selectedPatch = 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); + service.Data.GetPatchesFor(selectedModification) + .Should().ContainSingle(patch => patch.Name == "BalancePatch"); + service.Data.GetAddonsFor(selectedModification, selectedPatch) + .Should().Contain(addon => addon.Name == "HDTextures") + .And.Contain(addon => addon.Name == "PatchAddon"); + service.Data.Addons.Should().OnlyContain(addon => + addon.IsSelected && + addon.Versions.Count(version => version.Installation.IsSelected) == 1); + service.Data.GetAllModsVersionsList().Should().HaveCount(2); + } + + [Fact] + public async Task UninstallVersionDeletesLocalFilesAndReconcilesCatalogAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + new StubRemoteYamlDocumentReader(), + new RecordingRemoteAssetDownloader()); + LauncherContentVersion version = new() + { + Installation = new LauncherContentInstallation { Installed = true }, + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + }; + + stateStore.StateToLoad = new LauncherContentState(); + localContentService.InstalledVersions = Array.Empty(); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, paths), + CancellationToken.None); + + service.UninstallVersion(version.ContentKey); + + localContentService.DeletedVersions.Should().ContainSingle(request => + request.Paths == paths && + request.ContentKey.ContentType == ModificationType.Mod && + request.ContentKey.Name == "ShockWave" && + request.ContentKey.Version == "1.0"); + } + + [Fact] + public async Task AddRepositoryModificationAsyncAddsRemoteModAndCachesImagesExactlyOnceAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/contra.yaml"); + var imageUri = new Uri("https://cdn.example.test/contra.png"); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + var yamlReader = new StubRemoteYamlDocumentReader(); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + + stateStore.StateToLoad = new LauncherContentState(); + localContentService.InstalledVersions = Array.Empty(); + yamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = new List + { + new LegacyCatalogModificationReference + { + ModName = "Contra", + ModLink = modUri.ToString() + } + } + }); + yamlReader.SetResult(modUri, new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "009", + UIImageSourceLink = imageUri.ToString() + }); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, paths), + CancellationToken.None); + + LauncherContentVersion downloadedVersion = await service.AddRepositoryModificationAsync( + "Contra", + CancellationToken.None); + + downloadedVersion.Name.Should().Be("Contra"); + downloadedVersion.Version.Should().Be("009"); + LauncherContent addedModification = service.Data.Modifications.Should().ContainSingle().Subject; + addedModification.Name.Should().Be("Contra"); + addedModification.Versions.Should().ContainSingle().Which.Should().BeSameAs(downloadedVersion); + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == imageUri && + call.DestinationFilePath == paths.GetModificationImageFilePath("Contra", "009.png")); + } + + [Fact] + public async Task InitDataAsyncWaitsForOldGameMetadataLoadBeforeSwitchingCatalogAsync() + { + 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")); + 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 stateStore = new StubLauncherContentStateStore(); + var yamlReader = new StubRemoteYamlDocumentReader(); + var assetDownloader = new RecordingRemoteAssetDownloader(); + var metadataReadStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseMetadataRead = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + yamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + { + new LegacyCatalogModificationReference + { + ModName = "Contra", + ModLink = modUri.ToString(), + }, + }, + }); + 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(), + }; + }); + LauncherContentCatalogService service = CreateService( + stateStore, + new RecordingLocalLauncherContentService(), + yamlReader, + assetDownloader); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, generalsPaths), + CancellationToken.None); + Task oldGameDownload = + service.AddRepositoryModificationAsync("Contra", CancellationToken.None); + await metadataReadStarted.Task; + Task switchGame = service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, zeroHourPaths), + CancellationToken.None); + + releaseMetadataRead.SetResult(); + await oldGameDownload; + await switchGame; + + service.Data.Modifications.Should().BeEmpty(); + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == imageUri && + call.DestinationFilePath == generalsPaths.GetModificationImageFilePath("Contra", "009.png")); + } + + [Fact] + public async Task InitDataAsyncWaitsForDirectMetadataReadBeforeSwitchingCatalogAsync() + { + 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")); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/contra.yaml"); + var yamlReader = new StubRemoteYamlDocumentReader(); + var metadataReadStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseMetadataRead = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + yamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + { + new LegacyCatalogModificationReference + { + ModName = "Contra", + ModLink = modUri.ToString(), + }, + }, + }); + yamlReader.SetHandler( + modUri, + async (_, cancellationToken) => + { + metadataReadStarted.SetResult(); + await releaseMetadataRead.Task.WaitAsync(cancellationToken); + return new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "009", + }; + }); + LauncherContentCatalogService service = CreateService( + new StubLauncherContentStateStore(), + new RecordingLocalLauncherContentService(), + yamlReader, + new RecordingRemoteAssetDownloader()); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, generalsPaths), + CancellationToken.None); + Task oldGameMetadata = service.GetRepositoryModificationMetadataAsync( + "Contra", + CancellationToken.None); + await metadataReadStarted.Task; + Task switchGame = 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"); + service.Data.Modifications.Should().BeEmpty(); + Func readOldMetadataFromNewSession = () => service.GetRepositoryModificationMetadataAsync( + "Contra", + CancellationToken.None); + await readOldMetadataFromNewSession.Should().ThrowAsync(); + } + + [Fact] + public async Task DiscardVersionDeletesFolderAndCatalogVersionAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + new StubRemoteYamlDocumentReader(), + new RecordingRemoteAssetDownloader()); + LauncherContentVersion version = new() + { + Installation = new LauncherContentInstallation { Installed = true }, + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + }; + stateStore.StateToLoad = CreateState(version); + localContentService.InstalledVersions = [version]; + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, paths), + CancellationToken.None); + localContentService.InstalledVersions = []; + + service.DiscardVersion(version.ContentKey); + + service.Data.Modifications.Select(modification => modification.Name).Should().NotContain("ShockWave"); + localContentService.DeletedVersions.Should().ContainSingle(request => + request.Paths == paths && + request.ContentKey.ContentType == ModificationType.Mod && + request.ContentKey.Name == "ShockWave" && + request.ContentKey.Version == "1.0"); + } + + [Fact] + public async Task DiscardContentDeletesEveryVersionFolderAndCatalogEntryAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + new StubRemoteYamlDocumentReader(), + new RecordingRemoteAssetDownloader()); + LauncherContentVersion version = new() + { + Installation = new LauncherContentInstallation { Installed = true }, + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + }; + LauncherContentVersion secondVersion = new() + { + Installation = new LauncherContentInstallation { Installed = true }, + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "2.0", + }; + stateStore.StateToLoad = CreateState(version, secondVersion); + localContentService.InstalledVersions = [version, secondVersion]; + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, paths), + CancellationToken.None); + localContentService.InstalledVersions = []; + + service.DiscardContent(version.ContentKey); + + service.Data.Modifications.Select(modification => modification.Name).Should().NotContain("ShockWave"); + localContentService.DeletedContents.Should().ContainSingle(request => + request.Paths == paths && + request.ContentKey.ContentType == ModificationType.Mod && + request.ContentKey.Name == "ShockWave" && + request.ContentKey.Version == "1.0"); + localContentService.DeletedVersions.Should().BeEmpty(); + } + + [Fact] + public async Task SaveLauncherDataPersistsInstalledAndAddedRepositoryModsAsync() + { + using var directory = new TestDirectory(); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + var yamlReader = new StubRemoteYamlDocumentReader(); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + LauncherContentVersion installedVersion = new() + { + Installation = new LauncherContentInstallation { Installed = true, IsSelected = true }, + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + }; + LauncherContentVersion repositoryVersion = new() + { + Installation = new LauncherContentInstallation + { + ContentSourceKind = ContentSourceKind.ManagedSingleFile + }, + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "2.0" + }; + stateStore.StateToLoad = CreateState(installedVersion, repositoryVersion); + localContentService.InstalledVersions = [installedVersion]; + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest( + null, + CreatePaths(directory.Path)), + CancellationToken.None); + + service.SaveLauncherData(); + + 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 PersistedManagedRepositoryModSurvivesDisconnectedCatalogReloadAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService + { + InstalledVersions = [] + }; + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + new StubRemoteYamlDocumentReader(), + new RecordingRemoteAssetDownloader()); + LauncherContentVersion repositoryVersion = new() + { + Installation = new LauncherContentInstallation + { + ContentSourceKind = ContentSourceKind.ManagedSingleFile + }, + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "2.0" + }; + stateStore.StateToLoad = CreateState(repositoryVersion); + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, paths), + CancellationToken.None); + service.SaveLauncherData(); + stateStore.StateToLoad = stateStore.SavedStates.Single(); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, paths), + CancellationToken.None); + + service.Data.Modifications.Should().ContainSingle() + .Which.Name.Should().Be("Contra"); + } + + [Fact] + public async Task SaveLauncherDataPersistsOriginalGameSelectionWithoutModificationCardsAsync() + { + using var directory = new TestDirectory(); + var stateStore = new StubLauncherContentStateStore(); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + new StubRemoteYamlDocumentReader(), + new RecordingRemoteAssetDownloader()); + LauncherContentVersion patch = new() + { + Installation = new LauncherContentInstallation { Installed = true, IsSelected = true }, + ModificationType = ModificationType.Patch, + Name = "Original Patch", + Version = "1.0", + ParentContentName = LauncherContentKey.OriginalGame.Name, + }; + stateStore.StateToLoad = CreateState(patch); + localContentService.InstalledVersions = [patch]; + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest( + null, + CreatePaths(directory.Path)), + CancellationToken.None); + service.SaveLauncherData(); + + LauncherContentState savedState = 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 SaveLauncherDataWhenPersistenceFailsPreservesCatalogForRetryAsync() + { + using var directory = new TestDirectory(); + var stateStore = new StubLauncherContentStateStore(); + int saveAttempts = 0; + stateStore.SaveHandler = _ => + { + saveAttempts++; + if (saveAttempts == 1) + { + throw new IOException("Catalog file is locked."); + } + }; + var localContentService = new RecordingLocalLauncherContentService(); + LauncherContentVersion version = new() + { + Installation = new LauncherContentInstallation + { + Installed = true, + ContentSourceKind = ContentSourceKind.Manual + }, + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2", + }; + stateStore.StateToLoad = CreateState(version); + localContentService.InstalledVersions = [version]; + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + new StubRemoteYamlDocumentReader(), + new RecordingRemoteAssetDownloader()); + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest( + null, + CreatePaths(directory.Path)), + CancellationToken.None); + Action firstSave = service.SaveLauncherData; + + firstSave.Should().Throw() + .WithInnerException(); + service.Data.Modifications.Should().ContainSingle(modification => + modification.Name == "ShockWave" && + modification.Versions.Single().Installation.Installed); + + service.SaveLauncherData(); + + saveAttempts.Should().Be(2); + stateStore.SavedStates.Should().HaveCount(2); + stateStore.SavedStates.Should().OnlyContain(state => + state.Modifications.Count == 1 && + state.Modifications[0].Name == "ShockWave" && + state.Modifications[0].ModificationVersions[0].ContentSourceKind == ContentSourceKind.Manual); + } + + 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 LauncherContentCatalogService CreateService( + ILauncherContentStateStore stateStore, + ILocalLauncherContentService localContentService, + IRemoteYamlDocumentReader yamlReader, + IRemoteAssetDownloader assetDownloader) + { + return new LauncherContentCatalogService( + stateStore, + new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance), + new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance), + new LauncherLocalContentReconciler( + localContentService, + NullLogger.Instance), + NullLogger.Instance); + } + + private static LauncherPaths CreatePaths(string root) + { + return TestLauncherPaths.Create(root); + } + + private static LauncherContentState CreateSingleInstalledModificationState( + string name, + string version) + { + return new LauncherContentState + { + Modifications = + { + CreateEntry( + ModificationType.Mod, + name, + string.Empty, + isSelected: false, + version, + versionSelected: false), + }, + }; + } + + private static LauncherContentVersion CreateInstalledModificationVersion( + string name, + string version) + { + return new LauncherContentVersion + { + Installation = new LauncherContentInstallation { Installed = true }, + ModificationType = ModificationType.Mod, + Name = name, + Version = version, + }; + } + + 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 LauncherContentEntryState CreateEntry( + ModificationType type, + string name, + string parentContentName, + bool isSelected, + string version, + bool versionSelected) + { + return new LauncherContentEntryState + { + ModificationType = type, + Name = name, + DependenceName = parentContentName, + Installed = true, + IsSelected = isSelected, + ModificationVersions = new List + { + new LauncherContentVersionState + { + ModificationType = type, + Name = name, + Version = version, + DependenceName = parentContentName, + Installed = true, + IsSelected = versionSelected + } + } + }; + } + + 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(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentStateMapperTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentStateMapperTests.cs new file mode 100644 index 00000000..e3436511 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentStateMapperTests.cs @@ -0,0 +1,270 @@ +using System.Collections.Generic; +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 ToLauncherDataRestoresSelectedInstalledContentState() + { + var state = new LauncherContentState + { + Modifications = new List + { + CreateEntry("ShockWave", string.Empty, ModificationType.Mod, "1.0", true) + }, + Patches = new List + { + CreateEntry("ShockWave Patch", "ShockWave", ModificationType.Patch, "1.1", true) + }, + Addons = new List + { + 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 ToLauncherDataRestoresPersistedEntryOrder() + { + var state = new LauncherContentState + { + Modifications = new List + { + 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 ToLauncherDataDoesNotSelectEntryFromStaleVersionSelection() + { + var state = new LauncherContentState + { + Modifications = new List + { + CreateEntry("ShockWave", string.Empty, ModificationType.Mod, "1.0", false, versionSelected: 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 ToLauncherDataUsesEntryTypeForIncompleteLegacyChildVersionRecords() + { + var state = new LauncherContentState + { + Addons = new List + { + new LauncherContentEntryState + { + Name = "Compatibility Addon", + DependenceName = "ShockWave", + ModificationType = ModificationType.Addon, + ModificationVersions = new List + { + new LauncherContentVersionState + { + 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); + } + + [Fact] + public void ToLauncherDataIgnoresLegacyAdvertisingVersionRecords() + { + var state = new LauncherContentState + { + Modifications = new List + { + new LauncherContentEntryState + { + Name = "Featured", + ModificationType = ModificationType.Advertising, + ModificationVersions = new List + { + new LauncherContentVersionState + { + 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 ToLauncherContentStatePersistsAddedRepositoryModsButFiltersUninstalledChildren() + { + 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 ToLauncherContentStateDoesNotPersistVersionSelectionForUnselectedEntry() + { + 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 ToLauncherContentStatePersistsEntryOrder() + { + 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); + } + + 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 List + { + new LauncherContentVersionState + { + Version = version, + Installed = true, + IsSelected = versionSelected ?? selected, + ContentSourceKind = ContentSourceKind.Manual + } + } + }; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherLocalContentReconcilerTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherLocalContentReconcilerTests.cs new file mode 100644 index 00000000..cfb1697a --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherLocalContentReconcilerTests.cs @@ -0,0 +1,295 @@ +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 GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherLocalContentReconcilerTests +{ + [Fact] + public void ReconcileAddsUnregisteredLocalVersions() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + var launcherData = new LauncherData(); + localContentService.InstalledVersions = + [ + new LauncherContentVersion + { + Installation = new LauncherContentInstallation { Installed = true }, + ModificationType = ModificationType.Mod, + Name = "Local Only", + Version = "1.0", + } + ]; + + reconciler.Reconcile(launcherData, new List(), paths); + + launcherData.Modifications.Should().ContainSingle(mod => mod.Name == "Local Only"); + } + + [Fact] + public void ReconcileMarksMissingRemoteVersionsUninstalledAndDeletesMissingLocalOnlyVersions() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + var remoteVersion = new LauncherContentVersion + { + Installation = new LauncherContentInstallation { Installed = true }, + ModificationType = ModificationType.Mod, + Name = "Remote", + Version = "1.0", + }; + var localOnlyVersion = new LauncherContentVersion + { + Installation = new LauncherContentInstallation { Installed = true }, + ModificationType = ModificationType.Mod, + Name = "Local Only", + Version = "2.0", + }; + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(remoteVersion); + launcherData.AddOrUpdate(localOnlyVersion); + localContentService.InstalledVersions = []; + + reconciler.Reconcile( + launcherData, + new List { remoteVersion.ContentKey }, + paths); + + launcherData.Modifications.Should().ContainSingle(mod => mod.Name == "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 ReconcilePreservesAddedRepositoryModWhenRemoteCatalogIsUnavailable() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var localContentService = new RecordingLocalLauncherContentService + { + InstalledVersions = [] + }; + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + var addedVersion = new LauncherContentVersion + { + Installation = new LauncherContentInstallation + { + ContentSourceKind = ContentSourceKind.ManagedSingleFile + }, + ModificationType = ModificationType.Mod, + Name = "Added", + Version = "1.0", + }; + 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 ReconcileMarksStaleOriginalGameAddonUninstalled() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + var addon = new LauncherContentVersion + { + Installation = new LauncherContentInstallation { Installed = true }, + ModificationType = ModificationType.Addon, + ParentContentName = LauncherContentKey.OriginalGame.Name, + Name = "Original Game Addon", + Version = "1.0", + }; + 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 ReconcileMarksStaleOriginalGamePatchUninstalled() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + var patch = new LauncherContentVersion + { + Installation = new LauncherContentInstallation { Installed = true }, + ModificationType = ModificationType.Patch, + ParentContentName = LauncherContentKey.OriginalGame.Name, + Name = "Original Game Patch", + Version = "1.0", + }; + 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 ReconcileChecksAChildSharedByMultipleParentVersionsOnce() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + LauncherContentVersion firstParent = CreateVersion( + ModificationType.Mod, + "Parent", + "1.0"); + LauncherContentVersion secondParent = CreateVersion( + ModificationType.Mod, + "Parent", + "2.0"); + LauncherContentVersion child = CreateVersion( + ModificationType.Addon, + "Shared Child", + "1.0", + "Parent"); + child.Installation.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 ReconcileIsIndependentOfTheGloballySelectedPatch(string selectedPatchName) + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + LauncherContentVersion parent = CreateVersion(ModificationType.Mod, "Parent", "1.0"); + parent.Installation.IsSelected = true; + LauncherContentVersion firstPatch = CreateVersion( + ModificationType.Patch, + "First Patch", + "1.0", + parent.Name); + LauncherContentVersion secondPatch = CreateVersion( + ModificationType.Patch, + "Second Patch", + "1.0", + parent.Name); + firstPatch.Installation.IsSelected = selectedPatchName == firstPatch.Name; + secondPatch.Installation.IsSelected = selectedPatchName == secondPatch.Name; + LauncherContentVersion firstAddon = CreateVersion( + ModificationType.Addon, + "First Addon", + "1.0", + firstPatch.Name); + LauncherContentVersion secondAddon = CreateVersion( + ModificationType.Addon, + "Second Addon", + "1.0", + secondPatch.Name); + firstAddon.Installation.Installed = true; + secondAddon.Installation.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(); + } + + private static LauncherLocalContentReconciler CreateReconciler( + ILocalLauncherContentService localContentService) + { + return new LauncherLocalContentReconciler( + localContentService, + NullLogger.Instance); + } + + private static LauncherPaths CreatePaths(string root) + { + return TestLauncherPaths.Create(root); + } + + private static LauncherContentVersion CreateVersion( + ModificationType modificationType, + string name, + string version, + string parentContentName = "") + { + return new LauncherContentVersion + { + ModificationType = modificationType, + ParentContentName = parentContentName, + Name = name, + Version = version + }; + } + + private sealed class EnumerationCountingReadOnlyCollection : IReadOnlyCollection + { + private readonly IReadOnlyCollection _items; + + public EnumerationCountingReadOnlyCollection(IReadOnlyCollection items) + { + _items = items; + } + + public int Count => _items.Count; + + public int EnumerationCount { get; private set; } + + 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..92774926 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/RemoteLauncherCatalogClientTests.cs @@ -0,0 +1,229 @@ +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 GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class RemoteLauncherCatalogClientTests +{ + [Fact] + public async Task DownloadInstalledModDataAsyncReadsInstalledModsAndPreservesPartialFailuresAsync() + { + 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); + } + + [Fact] + public async Task ReadChildManifestsAsyncReturnsSuccessfulChildrenWhenOneChildFailsAsync() + { + 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() }, + parentContentName: 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 ReadCatalogAsyncMapsThirdPartyManifestToNormalizedCatalogAsync() + { + 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 GetModificationNamesReturnsCatalogModificationNames() + { + 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 DownloadModDataByNameAsyncReadsReferenceCaseInsensitivelyAsync() + { + 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 DownloadAdvertisingInfoAsyncReturnsManifestWhenReadSucceedsAsync() + { + 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 DownloadAdvertisingInfoAsyncReturnsNullWhenReadFailsAsync() + { + 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(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/YamlLauncherContentStateStoreTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/YamlLauncherContentStateStoreTests.cs new file mode 100644 index 00000000..d2282e48 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/YamlLauncherContentStateStoreTests.cs @@ -0,0 +1,258 @@ +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 GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class YamlLauncherContentStateStoreTests +{ + [Fact] + public void PersistedContentTypeValuesRemainCompatible() + { + ((int)ModificationType.Mod).Should().Be(0); + ((int)ModificationType.Addon).Should().Be(1); + ((int)ModificationType.Patch).Should().Be(2); + ((int)ModificationType.Advertising).Should().Be(3); + } + + [Theory] + [InlineData("Mod", 0)] + [InlineData("Addon", 1)] + [InlineData("Patch", 2)] + [InlineData("Advertising", 3)] + public void LoadAcceptsLegacyPersistedContentTypeNames( + string persistedValue, + int expectedTypeValue) + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory, SupportedGame.ZeroHour); + 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 SavePreservesLegacyPersistedContentTypeNames( + int contentTypeValue, + string persistedValue) + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory, SupportedGame.ZeroHour); + 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 LoadUsesEmptyContentStateAsDefaultDocument() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory, SupportedGame.ZeroHour); + 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 SaveKeepsIdenticalContentKeysIsolatedByGame() + { + using var directory = new TestDirectory(); + LauncherPaths generalsPaths = CreatePaths(directory, SupportedGame.Generals); + LauncherPaths zeroHourPaths = CreatePaths(directory, SupportedGame.ZeroHour); + 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 SavePreservesLauncherContentYamlKeysAndEnumValues() + { + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory, SupportedGame.ZeroHour); + 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, + 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("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.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, + }, + }, + }, + }, + }; + } + + private static LauncherPaths CreatePaths(TestDirectory directory, SupportedGame game) + { + string executableDirectory = Path.Combine(directory.Path, "Launcher"); + string gameDirectory = Path.Combine(directory.Path, game + "Game"); + Directory.CreateDirectory(executableDirectory); + Directory.CreateDirectory(gameDirectory); + var storagePaths = new LauncherStoragePaths(executableDirectory); + LauncherPaths paths = storagePaths.CreateGamePaths(game, gameDirectory); + Directory.CreateDirectory(paths.StateDirectory); + return paths; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Support/RemoteLauncherCatalogMapperTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Support/RemoteLauncherCatalogMapperTests.cs new file mode 100644 index 00000000..a2de3f30 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Support/RemoteLauncherCatalogMapperTests.cs @@ -0,0 +1,176 @@ +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; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Support; + +public sealed class RemoteLauncherCatalogMapperTests +{ + [Fact] + public async Task PublishedCatalogYamlMapsExactLegacyShapeAndLeavesVestigialGlobalAddonsUnpublishedAsync() + { + 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().ContainSingle(globalAddonUrl); + document.modDatas.Should().HaveCount(2); + 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().ContainSingle("https://example.test/shockwave-patch.yaml"); + modification.AddonManifestUrls.Should().ContainSingle("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().ContainSingle( + "https://example.test/original-addon.yaml"); + catalog.OriginalGamePatchManifestUrls.Should().ContainSingle( + "https://example.test/original-patch.yaml"); + + catalog.Modifications.SelectMany(entry => entry.AddonManifestUrls) + .Should().NotContain(globalAddonUrl); + catalog.OriginalGameAddonManifestUrls.Should().NotContain(globalAddonUrl); + } + + [Fact] + public async Task PublishedContentYamlMapsSupportedFieldsAndIgnoresRetiredThemeBlockAsync() + { + 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", + }); + } + + [Fact] + public async Task PublishedContentYamlUsesDeclaredSourceKindWhenPackageMetadataIsAbsentAsync() + { + 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 ToRemoteCatalogReturnsEmptyCatalogForNullManifest() + { + RemoteLauncherCatalog result = RemoteLauncherCatalogMapper.ToRemoteCatalog(null); + + result.Should().BeSameAs(RemoteLauncherCatalog.Empty); + } + + 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); + } + +} diff --git a/GenLauncherGO.Tests/Infrastructure/Persistence/Services/YamlDocumentStoreTests.cs b/GenLauncherGO.Tests/Infrastructure/Persistence/Services/YamlDocumentStoreTests.cs new file mode 100644 index 00000000..5157a580 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Persistence/Services/YamlDocumentStoreTests.cs @@ -0,0 +1,137 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Persistence.Services; + +public sealed class YamlDocumentStoreTests +{ + [Fact] + public void Load_WhenDocumentIsMissing_ReturnsDefaultDocument() + { + 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); + } + + [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(); + } + + [Fact] + public void AtomicWriter_WhenCommitFails_PreservesOriginalAndCleansTemporaryFile() + { + using var directory = new TestDirectory(); + string documentPath = Path.Combine(directory.Path, "state.yaml"); + var writer = new AtomicFileWriter(); + writer.WriteText(documentPath, "Name: original"); + 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.EnumerateFiles(directory.Path, ".*.tmp").Should().BeEmpty(); + } + + [Fact] + public async Task AtomicWriterAsync_WhenCanceledDuringWrite_PreservesOriginalAndCleansTemporaryFileAsync() + { + using var directory = new TestDirectory(); + string documentPath = Path.Combine(directory.Path, "state.yaml"); + await File.WriteAllTextAsync(documentPath, "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); + cancellationTokenSource.Cancel(); + cancellationToken.ThrowIfCancellationRequested(); + }, + cancellationTokenSource.Token); + + await act.Should().ThrowAsync(); + File.ReadAllText(documentPath).Should().Be("Name: original"); + Directory.EnumerateFiles(directory.Path, ".*.tmp").Should().BeEmpty(); + } + + 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..eeed1a5d --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteAssetDownloaderTests.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Remote; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Tests.Testing; +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"); + + RecordingFileDownloader fileDownloader = new(); + HttpRemoteAssetDownloader downloader = new( + fileDownloader, + NullLogger.Instance); + + await downloader.DownloadIfMissingAsync( + new Uri("https://example.test/asset.png"), + destinationFilePath, + CancellationToken.None); + + fileDownloader.Requests.Should().ContainSingle() + .Which.Resume.Should().BeFalse(); + File.ReadAllText(destinationFilePath).Should().Be("fresh"); + File.Exists(temporaryFilePath).Should().BeFalse(); + } + + private sealed class RecordingFileDownloader : IResumableFileDownloader + { + public List Requests { get; } = new(); + + public async Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + Requests.Add(request); + await File.WriteAllTextAsync(request.DestinationFilePath, "fresh", cancellationToken); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteConnectionProbeTests.cs b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteConnectionProbeTests.cs new file mode 100644 index 00000000..9fc3f43f --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteConnectionProbeTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Remote; +using GenLauncherGO.Tests.Testing; +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); + } + + [Fact] + public async Task CanConnectAsync_FallsBackToGetWhenHeadIsNotAllowedAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.MethodNotAllowed)); + 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(); + } + + 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..50bc3354 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteYamlDocumentReaderTests.cs @@ -0,0 +1,55 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Remote; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Remote; + +public sealed class HttpRemoteYamlDocumentReaderTests +{ + [Fact] + public async Task ReadYamlAsync_DeserializesRemoteYamlAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("Name: ShockWave\nVersion: '1.2'\n", 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(); + } + + private sealed class RemoteDocument + { + public string Name { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + } + +} diff --git a/GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.cs new file mode 100644 index 00000000..7bf108b9 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.cs @@ -0,0 +1,401 @@ +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 GenLauncherGO.Tests.Testing; +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.ZeroHour.GameArguments.Should().BeEmpty(); + } + + [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, + UseEnglishLanguage = 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", + 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("Games:"); + 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: Nested + ExecutableName: tools/custom.exe + CustomWorldBuilders: + - DisplayName: Editor + ExecutableName: editor.exe + Generals: + CustomGameClients: + - DisplayName: Generals Custom + ExecutableName: custom-one.exe + """); + + PreferencesService service = CreateService(preferencesFilePath); + + 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")); + } + + [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..f5b476d8 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Shell/Services/WindowsLauncherShellServiceTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using GenLauncherGO.Infrastructure.Shell.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Shell.Services; + +public sealed class WindowsLauncherShellServiceTests +{ + [Fact] + public void OpenUriDoesNotLaunchEmptyUri() + { + List openedTargets = new(); + WindowsLauncherShellService service = CreateService(openedTargets.Add); + + service.OpenUri(" "); + + openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenUriDoesNotLaunchRelativeUri() + { + List openedTargets = new(); + WindowsLauncherShellService service = CreateService(openedTargets.Add); + + service.OpenUri("not-a-uri"); + + openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenUriDoesNotLaunchUnsupportedScheme() + { + List openedTargets = new(); + WindowsLauncherShellService service = CreateService(openedTargets.Add); + + service.OpenUri("ftp://example.test/file.big"); + + openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenUriOpensNormalizedHttpTarget() + { + List openedTargets = new(); + WindowsLauncherShellService service = CreateService(openedTargets.Add); + + service.OpenUri("HTTPS://Example.Test/mods?id=1"); + + openedTargets.Should().Equal("https://example.test/mods?id=1"); + } + + [Fact] + public void OpenUriDoesNotPropagateShellOpenFailure() + { + WindowsLauncherShellService service = CreateService(_ => throw new Win32Exception(5)); + + Action act = () => service.OpenUri("https://example.test/mods"); + + act.Should().NotThrow(); + } + + [Fact] + public void OpenFolderDoesNotLaunchEmptyFolder() + { + List openedTargets = new(); + WindowsLauncherShellService service = CreateService(openedTargets.Add); + + service.OpenFolder(" "); + + openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenFolderDoesNotLaunchInvalidPath() + { + List openedTargets = new(); + WindowsLauncherShellService service = CreateService(openedTargets.Add); + + service.OpenFolder("bad\0path"); + + openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenFolderDoesNotLaunchMissingFolder() + { + using TestDirectory directory = new(); + List openedTargets = new(); + WindowsLauncherShellService service = CreateService(openedTargets.Add); + string missingFolder = Path.Combine(directory.Path, "missing"); + + service.OpenFolder(missingFolder); + + openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenFolderCreatesMissingFolderWhenRequested() + { + using TestDirectory directory = new(); + string missingFolder = Path.Combine(directory.Path, "Logs"); + List openedTargets = new(); + WindowsLauncherShellService service = CreateService(openedTargets.Add); + + service.OpenFolder(missingFolder, createIfMissing: true); + + Directory.Exists(missingFolder).Should().BeTrue(); + openedTargets.Should().Equal(Path.GetFullPath(missingFolder)); + } + + [Fact] + public void OpenFolderDoesNotLaunchWhenMissingFolderCannotBeCreated() + { + using TestDirectory directory = new(); + string filePath = Path.Combine(directory.Path, "Logs"); + File.WriteAllText(filePath, "not a directory"); + List openedTargets = new(); + WindowsLauncherShellService service = CreateService(openedTargets.Add); + + service.OpenFolder(filePath, createIfMissing: true); + + openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenFolderDoesNotLaunchEmptyFolderWhenFilesAreRequired() + { + using TestDirectory directory = new(); + List openedTargets = new(); + WindowsLauncherShellService service = CreateService(openedTargets.Add); + + service.OpenFolder(directory.Path, requireFiles: true); + + openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenFolderOpensExistingFolder() + { + using TestDirectory directory = new(); + List openedTargets = new(); + WindowsLauncherShellService service = CreateService(openedTargets.Add); + + service.OpenFolder(directory.Path); + + openedTargets.Should().Equal(Path.GetFullPath(directory.Path)); + } + + [Fact] + public void OpenFolderOpensExistingFolderWhenRequiredFilesExist() + { + using TestDirectory directory = new(); + File.WriteAllText(Path.Combine(directory.Path, "file.txt"), "content"); + List openedTargets = new(); + WindowsLauncherShellService service = CreateService(openedTargets.Add); + + service.OpenFolder(directory.Path, requireFiles: true); + + openedTargets.Should().Equal(Path.GetFullPath(directory.Path)); + } + + 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..b41b2e3b --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherPathResolverTests.cs @@ -0,0 +1,74 @@ +using System.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Startup; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Startup; + +public sealed class FileSystemLauncherPathResolverTests +{ + [Fact] + public void ResolveAlwaysUsesExecutableDirectoryWithoutInferringAGame() + { + using var directory = new TestDirectory(); + var resolver = new FileSystemLauncherPathResolver(); + + LauncherStoragePaths paths = resolver.Resolve(directory.Path); + + paths.ExecutableDirectory.Should().Be(Path.GetFullPath(directory.Path)); + paths.DataDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO Data")); + paths.LogsDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO Data", "Logs")); + paths.PreferencesFilePath.Should().Be( + Path.Combine(directory.Path, "GenLauncherGO Data", "LauncherPreferences.yaml")); + } + + [Fact] + public void PrepareLauncherDirectoriesCreatesOnlySharedStorage() + { + 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 PrepareGameDirectoriesCreatesIsolatedLayoutAndClearsOnlyTemp() + { + 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, cleanTemporaryDirectory: 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(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationRegistryTests.cs b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationRegistryTests.cs new file mode 100644 index 00000000..d39f8ce0 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationRegistryTests.cs @@ -0,0 +1,101 @@ +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 ReadCandidatesQueriesGeneralsSourcesInSteamEaRetailOrder() + { + 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, GeneralsKey, "InstallPath"), + (RegistryView.Registry64, GeneralsKey, "InstallPath"), + (RegistryView.Registry32, FirstDecadeKey, "gr_folder"), + (RegistryView.Registry64, FirstDecadeKey, "gr_folder")); + candidates.Should().Equal( + @"C:\Candidate1", + @"C:\Candidate2", + @"C:\Candidate3", + @"C:\Candidate4", + @"C:\Candidate5", + @"C:\Candidate6"); + } + + [Fact] + public void ReadCandidatesQueriesZeroHourSourcesInSteamEaRetailOrder() + { + 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, ZeroHourSteamKey, "installPath"), + (RegistryView.Registry64, ZeroHourSteamKey, "installPath"), + (RegistryView.Registry32, ZeroHourEaKey, "InstallPath"), + (RegistryView.Registry64, ZeroHourEaKey, "InstallPath"), + (RegistryView.Registry32, FirstDecadeKey, "zh_folder"), + (RegistryView.Registry64, FirstDecadeKey, "zh_folder")); + candidates.Should().Equal( + @"C:\Candidate1", + @"C:\Candidate2", + @"C:\Candidate3", + @"C:\Candidate4", + @"C:\Candidate5", + @"C:\Candidate6"); + } + + [Fact] + public void ReadCandidatesDeduplicatesEquivalentPathsWithoutChangingPriority() + { + 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..c92d8cb5 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationServiceTests.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Models; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Startup; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Startup; + +public sealed class WindowsGameInstallationServiceTests +{ + [Theory] + [InlineData(SupportedGame.Generals, "Window.big", "generalsv.exe")] + [InlineData(SupportedGame.ZeroHour, "WindowZH.big", "generalszh.exe")] + [InlineData(SupportedGame.ZeroHour, "WindowZH.big", "generalsonlinezh.exe")] + public void ValidateAcceptsCanonicalFilesAndReturnsPhysicalPath( + SupportedGame game, + string archiveName, + string executableName) + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = CreateGame(directory, "Game", archiveName, 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, "Window.big.GLR", "generalsv.exe")] + [InlineData(SupportedGame.ZeroHour, "WindowZH.big.GLR", "generalszh.exe")] + public void ValidateRejectsLegacyRenamedArchive( + SupportedGame game, + string archiveName, + string executableName) + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = CreateGame(directory, "Game", archiveName, executableName); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(game, gameDirectory, executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.RequiredFilesMissing); + } + + [Fact] + public void ValidateRejectsFolderForDifferentSupportedGame() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string generalsDirectory = CreateGame(directory, "Generals", "Window.big", "generalsv.exe"); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.ZeroHour, generalsDirectory, executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.RequiredFilesMissing); + result.CanonicalPath.Should().BeNull(); + } + + [Fact] + public void ValidateRejectsExecutableInsideGameInstallation() + { + using var directory = new TestDirectory(); + string gameDirectory = CreateGame(directory, "Game", "WindowZH.big", "generalszh.exe"); + 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 ValidateAcceptsGameInstallationBelowExecutableDirectoryWhenOutsideLauncherData() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = CreateGame( + directory, + Path.Combine("Launcher", "Game"), + "Window.big", + "generalsv.exe"); + 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 ValidateRejectsGameInstallationInsideLauncherOwnedData() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = CreateGame( + directory, + Path.Combine("Launcher", LauncherFileSystemLayout.LauncherDataFolderName, "Game"), + "Window.big", + "generalsv.exe"); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.Generals, gameDirectory, executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.UnsafeFileSystemPath); + } + + [SymbolicLinkFact] + public void ValidateRejectsInstallationReachedThroughSymbolicLink() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = CreateGame(directory, "RealGame", "Window.big", "generalsv.exe"); + string linkedDirectory = directory.GetPath("LinkedGame"); + Directory.CreateSymbolicLink(linkedDirectory, gameDirectory); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.Generals, linkedDirectory, executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.UnsafeFileSystemPath); + } + + [Fact] + public void DiscoverValidInstallationsNeverOverwritesValidConfiguredPath() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string configuredDirectory = CreateGame( + directory, + "ConfiguredGenerals", + "Window.big", + "generalsv.exe"); + string registryDirectory = CreateGame( + directory, + "RegistryGenerals", + "Window.big", + "generalsv.exe"); + 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 DiscoverValidInstallationsSkipsInvalidRegistryCandidateAndFillsMissingPath() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string invalidDirectory = directory.CreateDirectory("NotAGame"); + string validDirectory = CreateGame( + directory, + "ZeroHour", + "WindowZH.big", + "generalszh.exe"); + var registry = new FakeRegistry(); + registry.Add(SupportedGame.ZeroHour, invalidDirectory, 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 DiscoverValidInstallationsUsesFirstValidRegistryCandidate() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string steamDirectory = CreateGame( + directory, + "SteamZeroHour", + "WindowZH.big", + "generalszh.exe"); + string eaDirectory = CreateGame( + directory, + "EaZeroHour", + "WindowZH.big", + "generalszh.exe"); + 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)); + } + + [Fact] + public void ValidateRejectsInstallationContainingBothGameMarkerSets() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string combinedDirectory = CreateGame( + directory, + "Combined", + "Window.big", + "generalsv.exe"); + File.WriteAllText(Path.Combine(combinedDirectory, "WindowZH.big"), string.Empty); + File.WriteAllText(Path.Combine(combinedDirectory, "generalszh.exe"), string.Empty); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult generals = + service.Validate(SupportedGame.Generals, combinedDirectory, executableDirectory); + GameInstallationValidationResult zeroHour = + service.Validate(SupportedGame.ZeroHour, combinedDirectory, executableDirectory); + + generals.Failure.Should().Be(GameInstallationValidationFailure.RequiredFilesMissing); + zeroHour.Failure.Should().Be(GameInstallationValidationFailure.RequiredFilesMissing); + } + + [Fact] + public void FindContainingInstallationDetectsGameBeforeStandaloneStorageIsCreated() + { + using var directory = new TestDirectory(); + string gameDirectory = CreateGame( + directory, + "ZeroHour", + "WindowZH.big", + "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 CreateGame( + TestDirectory directory, + string relativeDirectory, + string archiveName, + string executableName) + { + string gameDirectory = directory.CreateDirectory(relativeDirectory); + File.WriteAllText(Path.Combine(gameDirectory, "BINKW32.DLL"), string.Empty); + File.WriteAllText(Path.Combine(gameDirectory, archiveName), string.Empty); + File.WriteAllText(Path.Combine(gameDirectory, executableName), string.Empty); + return gameDirectory; + } + + private sealed class FakeRegistry : IGameInstallationRegistry + { + private readonly Dictionary> _candidates = new(); + + public void Add(SupportedGame game, params string[] candidates) + { + _candidates[game] = candidates; + } + + public IReadOnlyList ReadCandidates(SupportedGame game) + { + return _candidates.TryGetValue(game, out IReadOnlyList? candidates) + ? candidates + : Array.Empty(); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs new file mode 100644 index 00000000..03fdede8 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs @@ -0,0 +1,123 @@ +using System; +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 +{ + [Fact] + public void GetExecutableDirectoryReturnsExistingDirectory() + { + var service = new WindowsLauncherHostEnvironmentService(); + + string directory = service.GetExecutableDirectory(); + + directory.Should().NotBeNullOrWhiteSpace(); + Directory.Exists(directory).Should().BeTrue(); + } + + [Fact] + public void TryAcquireSingleInstanceReturnsAcquiredGuardForUnusedName() + { + var service = new WindowsLauncherHostEnvironmentService(); + string instanceName = CreateInstanceName(); + + using ILauncherSingleInstanceGuard guard = service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + guard.IsAcquired.Should().BeTrue(); + } + + [Fact] + public async Task TryAcquireSingleInstanceReturnsAcquiredGuardWhenNameIsReleasedBeforeRetryAsync() + { + 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(initiallyOwned: true, instanceName, out _); + mutexAcquired.Set(); + releaseMutex.Wait(); + owner.ReleaseMutex(); + } + catch (Exception exception) + { + ownerException = exception; + mutexAcquired.Set(); + } + }) + { + IsBackground = true, + }; + + ownerThread.Start(); + try + { + mutexAcquired.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue(); + + Task acquisition = Task.Run(() => + service.TryAcquireSingleInstance(instanceName, TimeSpan.FromMilliseconds(100))); + retryStarted.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue(); + + releaseMutex.Set(); + ownerThread.Join(); + allowRetry.Set(); + using ILauncherSingleInstanceGuard guard = + await acquisition.WaitAsync(TimeSpan.FromSeconds(5)); + + ownerException.Should().BeNull(); + guard.IsAcquired.Should().BeTrue(); + } + finally + { + releaseMutex.Set(); + allowRetry.Set(); + ownerThread.Join(TimeSpan.FromSeconds(5)); + } + } + + [Fact] + public void TryAcquireSingleInstanceReturnsRejectedGuardWhenNameIsAlreadyOwned() + { + 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(); + } + + [Fact] + public void IsProtectedProgramFilesDirectoryReturnsFalseForTemporaryDirectory() + { + var service = new WindowsLauncherHostEnvironmentService(); + + bool result = service.IsProtectedProgramFilesDirectory(Path.GetTempPath()); + + result.Should().BeFalse(); + } + + 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..2b6178e2 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/HttpDownloadFileMetadataReaderTests.cs @@ -0,0 +1,109 @@ +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; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Clients; + +public sealed class HttpDownloadFileMetadataReaderTests +{ + [Fact] + public async Task ReadMetadataAsync_UsesHeadContentDispositionFileNameStarAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse( + HttpStatusCode.OK, + contentDisposition: "attachment; filename*=UTF-8''Folder%2FPackage.big", + contentLength: 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("FolderPackage.big"); + metadata.TotalBytes.Should().Be(123); + handler.Methods.Should().Equal(HttpMethod.Head); + } + + [Fact] + public async Task ReadMetadataAsync_FallsBackToGetWhenHeadIsNotAllowedAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.MethodNotAllowed)); + handler.Enqueue(_ => CreateResponse( + HttpStatusCode.OK, + contentDisposition: "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); + } + + [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); + } + + [Fact] + public async Task ReadMetadataAsync_ThrowsWhenSanitizedFileNameIsEmptyAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse( + HttpStatusCode.OK, + contentDisposition: "attachment; filename=\"\\\\\"")); + 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."); + } + + 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(Array.Empty()), + }; + 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..0a5fdada --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioClientFactoryTests.cs @@ -0,0 +1,23 @@ +using Minio; +using Subject = GenLauncherGO.Infrastructure.Updating.Clients.MinioClientFactory; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Clients; + +public sealed class MinioClientFactoryTests +{ + [Theory] + [InlineData("s3.example.test")] + [InlineData("http://s3.example.test:9000/path")] + [InlineData("https://s3.example.test")] + [InlineData("s3.example.test:443")] + public void CreateBuildsClientForSupportedEndpointForms(string endpoint) + { + IMinioClient client = Subject.Create( + endpoint, + "access", + "secret", + useSsl: false); + + client.Should().NotBeNull(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioS3ObjectManifestReaderTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioS3ObjectManifestReaderTests.cs new file mode 100644 index 00000000..e47d7f7b --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioS3ObjectManifestReaderTests.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +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 +{ + [Fact] + public async Task ReadManifestAsyncReturnsPrefixRelativeEntriesAsync() + { + S3ObjectManifestRequest? receivedRequest = null; + using CancellationTokenSource cancellationTokenSource = new(); + S3ObjectManifestRequest request = CreateRequest(prefix: "ShockWave/1.2"); + 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..e8183c62 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/ResumableHttpFileDownloaderTests.cs @@ -0,0 +1,513 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Clients; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Clients; + +public sealed class ResumableHttpFileDownloaderTests +{ + [Theory] + [InlineData("RelativeUri")] + [InlineData("UnsupportedScheme")] + [InlineData("MissingDestination")] + public async Task DownloadFileAsyncThrowsForInvalidRequestAsync(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); + + File.ReadAllBytes(destinationFilePath).Should().Equal(payload); + progress.Reports.Should().Contain(report => report.BytesDownloaded == payload.Length); + } + + [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); + + 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, ExpectedBytes: 6), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().Be("bytes=3-"); + File.ReadAllText(destinationFilePath).Should().Be("abcdef"); + } + + [Fact] + public async Task DownloadFileAsync_RestartsWhenServerIgnoresRangeRequestAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.zip"); + await File.WriteAllTextAsync(destinationFilePath, "partial"); + + byte[] fullPayload = Encoding.UTF8.GetBytes("fresh"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, fullPayload)); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + 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-"); + File.ReadAllText(destinationFilePath).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"); + + 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, ExpectedBytes: 6), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().Equal("bytes=3-", null); + File.ReadAllText(destinationFilePath).Should().Be("abcdef"); + } + + [Fact] + public async Task DownloadFileAsyncReturnsExistingCompleteFileWithoutRequestAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "ready"); + QueueHttpMessageHandler handler = new(); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + RecordingProgress progress = new(); + + await downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + ExpectedBytes: 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 DownloadFileAsyncRestartsWhenExistingFileIsLargerThanExpectedAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "too-large"); + 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, + ExpectedBytes: 5), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().BeNull(); + File.ReadAllText(destinationFilePath).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, + chunkSize: 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(); + } + + [Fact] + public async Task DownloadFileAsync_PauseStopsTransferUntilResumedAsync() + { + byte[] payload = Encoding.UTF8.GetBytes("abcdef"); + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, payload)); + 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); + while (handler.Requests.Count == 0) + { + await Task.Yield(); + } + + download.IsCompleted.Should().BeFalse(); + new FileInfo(destinationFilePath).Length.Should().Be(0); + + pauseController.Resume().Should().BeTrue(); + await download.WaitAsync(TimeSpan.FromSeconds(5)); + + File.ReadAllBytes(destinationFilePath).Should().Equal(payload); + } + + [Fact] + public async Task DownloadFileAsync_CancellationInterruptsPausedTransferAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes("abcdef"))); + 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); + while (handler.Requests.Count == 0) + { + await Task.Yield(); + } + + await cancellation.CancelAsync(); + + Func act = () => download; + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DownloadFileAsyncThrowsAfterFinalRetriableFailureAsync() + { + 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 DownloadFileAsyncThrowsWhenDownloadedBytesDoNotMatchExpectedBytesAsync() + { + 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, maxAttempts: 1); + + Func act = () => downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + ExpectedBytes: 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 DownloadFileAsyncThrowsWhenTransferStallsAsync() + { + 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, + maxAttempts: 1, + idleTimeout: 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(); + } + + private static ResumableHttpFileDownloader CreateDownloader( + QueueHttpMessageHandler handler, + int maxAttempts = 2, + TimeSpan? idleTimeout = null, + TimeProvider? timeProvider = null) + { + HttpClient httpClient = new(handler) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + + return new ResumableHttpFileDownloader( + httpClient, + logger: null, + bufferSize: 4, + maxAttempts: maxAttempts, + idleTimeout: idleTimeout ?? TimeSpan.FromSeconds(5), + progressReportInterval: TimeSpan.FromMilliseconds(1), + initialRetryDelay: TimeSpan.FromMilliseconds(1), + timeProvider); + } + + private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, byte[] payload) + { + return new HttpResponseMessage(statusCode) + { + Content = new ByteArrayContent(payload), + }; + } + + private sealed class ChunkStream : Stream + { + private readonly Action _beforeRead; + private readonly byte[] _payload; + private readonly int _chunkSize; + 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 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(); + } + } + + private sealed class RecordingProgress : IProgress + { + private readonly List _reports = new(); + + public IReadOnlyList Reports => _reports; + + public void Report(T value) + { + _reports.Add(value); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs new file mode 100644 index 00000000..146345b5 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs @@ -0,0 +1,55 @@ +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 S3ObjectManifestRequest_DefaultsHostOnlyEndpointToNonSsl() + { + S3ObjectManifestRequest request = new( + "gen.insave.ovh:9000", + "mods", + "folder", + "access", + "secret"); + + request.UseSsl.Should().BeFalse(); + } + + [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(S3CatalogDefaults.PublicAccessKey); + request.SecretKey.Should().Be(S3CatalogDefaults.PublicSecretKey); + } + + [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/Md5FileHashServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/Md5FileHashServiceTests.cs new file mode 100644 index 00000000..13387d34 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/Md5FileHashServiceTests.cs @@ -0,0 +1,24 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Services; +using GenLauncherGO.Tests.Testing; + +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"); + var service = new Md5FileHashService(); + + string hash = await service.ComputeMd5HashAsync(filePath, CancellationToken.None); + + hash.Should().Be("900150983CD24FB0D6963F7D28E17F72"); + } + +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadServiceTests.cs new file mode 100644 index 00000000..5fabb625 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadServiceTests.cs @@ -0,0 +1,509 @@ +using System; +using System.Collections.Concurrent; +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.Models; +using GenLauncherGO.Core.Mods.Services; +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 GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class PackageDownloadServiceTests +{ + [Fact] + public async Task DownloadAsyncUsesLatestVersionLinkAndOwnedPathsForSingleFilePackageAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + LauncherContentVersion oldVersion = CreateSingleFileVersion( + "ShockWave", + "1.0", + "https://example.test/old.zip"); + LauncherContentVersion latestVersion = CreateSingleFileVersion( + "ShockWave", + "2.0", + "https://www.dropbox.com/s/package/latest.zip?dl=0"); + LauncherContent modification = CreateModification(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); + (Uri SourceUri, PackageUpdatePathSet Paths) request = updater.Requests.Should().ContainSingle().Which; + request.SourceUri.Should().Be(new Uri("https://www.dropbox.com/s/package/latest.zip?dl=1")); + request.Paths.InstalledPath.FullPath.Should().Be(Path.Combine(paths.ModsDirectory, "ShockWave", "2.0")); + request.Paths.TemporaryPath.FullPath.Should() + .Be(Path.Combine(paths.TempDirectory, "Packages", "ShockWave", "2.0")); + request.Paths.BackupPath.FullPath.Should() + .Be(Path.Combine(paths.StateDirectory, "PackageBackups", "ShockWave", "2.0")); + request.Paths.InstalledPath.OwnerRoot.Should().Be(paths.ModsDirectory); + request.Paths.TemporaryPath.OwnerRoot.Should().Be( + Path.Combine(paths.TempDirectory, "Packages")); + request.Paths.BackupPath.OwnerRoot.Should().Be( + Path.Combine(paths.StateDirectory, "PackageBackups")); + updater.PauseControllers.Should().ContainSingle().Which.Should().BeSameAs(pauseController); + } + + [Fact] + public async Task DownloadAsyncUsesNewGameStorageAfterRuntimeSwitchWithoutRebuildingServiceAsync() + { + 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 = CreateSingleFileVersion( + "Shared Mod", + "1.0", + "https://example.test/shared.zip"); + LauncherContent modification = CreateModification(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); + } + + [Fact] + public async Task DownloadAsyncUsesS3ManifestAndLatestInstalledVersionPathAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + LauncherContentVersion installedVersion = CreateS3Version("ShockWave", "1.0"); + installedVersion.Installation.Installed = true; + LauncherContentVersion latestVersion = CreateS3Version("ShockWave", "2.0"); + LauncherContent modification = CreateModification(installedVersion, latestVersion); + RemoteFileManifestEntry[] manifestEntries = + { + new("Data/file.big", "0123456789ABCDEF0123456789ABCDEF", 10), + }; + RecordingS3ObjectManifestReader manifestReader = new(manifestEntries); + RecordingS3PackageUpdater updater = new(); + PackageDownloadService service = CreateService( + paths, + s3PackageUpdater: updater, + manifestReader: manifestReader); + + PackageDownloadResult result = await service.DownloadAsync( + modification, + latestVersion, + null, + CancellationToken.None); + + result.Status.Should().Be(PackageDownloadStatus.Succeeded); + S3ObjectManifestRequest manifestRequest = manifestReader.Requests.Should().ContainSingle().Which; + manifestRequest.Endpoint.Should().Be("https://s3.example.test"); + manifestRequest.BucketName.Should().Be("mods"); + manifestRequest.Prefix.Should().Be("ShockWave/2.0"); + + S3PackageUpdateRequest updateRequest = updater.Requests.Should().ContainSingle().Which; + updateRequest.Files.Should().Equal(manifestEntries); + updateRequest.Source.Should().BeSameAs(manifestRequest); + updateRequest.PathSet.InstalledPath.FullPath.Should() + .Be(Path.Combine(paths.ModsDirectory, "ShockWave", "2.0")); + updateRequest.PathSet.LatestInstalledPath!.FullPath.Should() + .Be(Path.Combine(paths.ModsDirectory, "ShockWave", "1.0")); + updateRequest.HashCheckedExtensions.Should().Contain(".big"); + updateRequest.PathSet.InstalledPath.OwnerRoot.Should().Be(paths.ModsDirectory); + } + + [Fact] + public async Task DownloadAsyncReturnsCanceledWhenCancellationStopsPreCommitWorkAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + LauncherContentVersion version = CreateSingleFileVersion( + "ShockWave", + "2.0", + "https://example.test/latest.zip"); + BlockingSingleFilePackageUpdater updater = new(); + PackageDownloadService service = CreateService(paths, updater); + using CancellationTokenSource cancellation = new(); + + Task download = service.DownloadAsync( + CreateModification(version), + version, + null, + cancellation.Token); + await updater.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + cancellation.Cancel(); + PackageDownloadResult result = await download.WaitAsync(TimeSpan.FromSeconds(5)); + + result.Status.Should().Be(PackageDownloadStatus.Canceled); + } + + [Fact] + public async Task DownloadAsyncKeepsSuccessWhenCancellationArrivesAfterUpdaterCommitAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + LauncherContentVersion version = CreateSingleFileVersion( + "ShockWave", + "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( + CreateModification(version), + version, + null, + cancellation.Token); + + result.Status.Should().Be(PackageDownloadStatus.Succeeded); + } + + [Fact] + public async Task DownloadAsyncTreatsProviderFailureAfterCancellationAsCanceledAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + LauncherContentVersion version = CreateSingleFileVersion( + "ShockWave", + "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( + CreateModification(version), + version, + null, + cancellation.Token); + + result.Status.Should().Be(PackageDownloadStatus.Canceled); + } + + [Theory] + [MemberData(nameof(ExpectedFailureCases))] + public async Task DownloadAsyncReturnsSafeRecoverableFailureForExpectedPackageFailuresAsync( + Exception failure, + string expectedMessage) + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + LauncherContentVersion version = CreateSingleFileVersion( + "ShockWave", + "2.0", + "https://example.test/latest.zip"); + RecordingSingleFilePackageUpdater updater = new() + { + Update = (_, _, _) => throw failure, + }; + PackageDownloadService service = CreateService(paths, updater); + + PackageDownloadResult result = await service.DownloadAsync( + CreateModification(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 DownloadAsyncReturnsSafeUnexpectedFailureWithoutLeakingDiagnosticPathAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + LauncherContentVersion version = CreateSingleFileVersion( + "ShockWave", + "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( + CreateModification(version), + version, + null, + CancellationToken.None); + + result.Status.Should().Be(PackageDownloadStatus.UnexpectedFailure); + result.Message.Should().Be("An unexpected package download error occurred."); + } + + [Fact] + public async Task DownloadAsyncSerializesConcurrentProgressWithoutRegressionAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + LauncherContentVersion version = CreateSingleFileVersion( + "ShockWave", + "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); + ConcurrentQueue reports = new(); + + PackageDownloadResult result = await service.DownloadAsync( + CreateModification(version), + version, + new InlineRecordingProgress(reports), + CancellationToken.None); + + result.Status.Should().Be(PackageDownloadStatus.Succeeded); + PackageUpdateProgress[] delivered = reports.ToArray(); + delivered.Should().NotBeEmpty(); + delivered.Select(report => report.BytesRead) + .Should().BeInAscendingOrder(); + delivered.Select(report => report.ProgressPercentage!.Value) + .Should().BeInAscendingOrder() + .And.OnlyContain(value => value >= 0 && value <= 100); + } + + public static TheoryData ExpectedFailureCases => + new() + { + { + new TimeoutException("signed URL expired"), + "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." + }, + }; + + private static PackageDownloadService CreateService( + LauncherPaths paths, + ISingleFilePackageUpdater? singleFilePackageUpdater = null, + IS3PackageUpdater? s3PackageUpdater = null, + IS3ObjectManifestReader? manifestReader = null) + { + return CreateService( + TestLauncherPaths.CreateRuntimePathContext(paths), + singleFilePackageUpdater, + s3PackageUpdater, + manifestReader); + } + + private static PackageDownloadService CreateService( + LauncherRuntimePathContext runtimePathContext, + ISingleFilePackageUpdater? singleFilePackageUpdater = null, + IS3PackageUpdater? s3PackageUpdater = null, + IS3ObjectManifestReader? manifestReader = null) + { + return new PackageDownloadService( + singleFilePackageUpdater ?? new RecordingSingleFilePackageUpdater(), + s3PackageUpdater ?? new RecordingS3PackageUpdater(), + manifestReader ?? new RecordingS3ObjectManifestReader(), + runtimePathContext, + NullLogger.Instance); + } + + private static LauncherPaths CreatePaths(string root) + { + return TestLauncherPaths.Create(Path.Combine(root, "Game")); + } + + private static LauncherContentVersion CreateSingleFileVersion( + string name, + string version, + string downloadLink) + { + return new LauncherContentVersion + { + Installation = new LauncherContentInstallation { ContentSourceKind = ContentSourceKind.ManagedSingleFile }, + ModificationType = ModificationType.Mod, + Name = name, + Version = version, + SimpleDownloadLink = downloadLink, + }; + } + + private static LauncherContentVersion CreateS3Version(string name, string version) + { + return new LauncherContentVersion + { + Installation = new LauncherContentInstallation { ContentSourceKind = ContentSourceKind.ManagedS3 }, + ModificationType = ModificationType.Mod, + Name = name, + Version = version, + S3HostLink = "https://s3.example.test", + S3BucketName = "mods", + S3FolderName = $"{name}/{version}", + S3HostPublicKey = "access-key", + S3HostSecretKey = "secret-key", + }; + } + + private static LauncherContent CreateModification(params LauncherContentVersion[] versions) + { + var data = new LauncherData(); + foreach (LauncherContentVersion version in versions) + { + data.AddOrUpdate(version); + } + + return data.FindContent(versions[0].ContentKey)!; + } + + private sealed class RecordingSingleFilePackageUpdater : ISingleFilePackageUpdater + { + public List<(Uri SourceUri, PackageUpdatePathSet Paths)> Requests { get; } = new(); + + public List PauseControllers { get; } = new(); + + public Func< + Uri, + IProgress?, + CancellationToken, + Task>? Update + { get; init; } + + public Task UpdateAsync( + Uri sourceUri, + PackageUpdatePathSet paths, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + Requests.Add((sourceUri, paths)); + PauseControllers.Add(pauseController); + return Update?.Invoke(sourceUri, progress, cancellationToken) ?? Task.CompletedTask; + } + } + + private sealed class BlockingSingleFilePackageUpdater : ISingleFilePackageUpdater + { + public TaskCompletionSource Started { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async Task UpdateAsync( + Uri sourceUri, + PackageUpdatePathSet paths, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + Started.TrySetResult(true); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + } + + private sealed class RecordingS3PackageUpdater : IS3PackageUpdater + { + public List Requests { get; } = new(); + + public Task UpdateAsync( + S3PackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + Requests.Add(request); + return Task.CompletedTask; + } + + public Task RepairFilesAsync( + S3PackageFileRepairRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } + + private sealed class RecordingS3ObjectManifestReader : IS3ObjectManifestReader + { + private readonly IReadOnlyList _files; + + public RecordingS3ObjectManifestReader() + : this(Array.Empty()) + { + } + + public RecordingS3ObjectManifestReader(IReadOnlyList files) + { + _files = files; + } + + public List Requests { get; } = new(); + + public Task> ReadManifestAsync( + S3ObjectManifestRequest request, + CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(_files); + } + } + + private sealed class InlineRecordingProgress : IProgress + { + private readonly ConcurrentQueue _reports; + + public InlineRecordingProgress(ConcurrentQueue reports) + { + _reports = reports; + } + + public void Report(PackageUpdateProgress value) + { + _reports.Enqueue(value); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/RemotePackageSizeResolverTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/RemotePackageSizeResolverTests.cs new file mode 100644 index 00000000..ebe76aab --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/RemotePackageSizeResolverTests.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +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 RemotePackageSizeResolverTests +{ + [Fact] + public async Task GetTotalBytesAsync_ReturnsDirectFileContentLengthAsync() + { + Uri downloadUri = new("https://example.test/contra.zip"); + RecordingMetadataReader metadataReader = new((uri, _) => + Task.FromResult(new DownloadFileMetadata(uri, "contra.zip", 3_355_443_200))); + RecordingManifestReader manifestReader = new(); + RemotePackageSizeResolver resolver = CreateResolver(metadataReader, manifestReader); + LauncherContentVersion version = new() + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "009", + SimpleDownloadLink = downloadUri.ToString(), + }; + + long? totalBytes = await resolver.GetTotalBytesAsync(version, CancellationToken.None); + + totalBytes.Should().Be(3_355_443_200); + metadataReader.RequestCount.Should().Be(1); + manifestReader.RequestCount.Should().Be(0); + } + + [Fact] + public async Task GetTotalBytesAsync_SumsS3ManifestIncludingEmptyManifestAsync() + { + RecordingMetadataReader metadataReader = new(); + RecordingManifestReader manifestReader = new( + new[] + { + new RemoteFileManifestEntry("Data/one.big", "hash", 10), + new RemoteFileManifestEntry("Data/two.big", "hash", 15), + }, + Array.Empty()); + RemotePackageSizeResolver 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.RequestCount.Should().Be(2); + } + + [Fact] + public async Task GetTotalBytesAsync_ReturnsUnavailableForUnsupportedSourceAsync() + { + RecordingMetadataReader metadataReader = new(); + RecordingManifestReader manifestReader = new(); + RemotePackageSizeResolver resolver = CreateResolver(metadataReader, manifestReader); + LauncherContentVersion version = new() + { + ModificationType = ModificationType.Mod, + Name = "Manual Mod", + Version = "1.0", + }; + + long? totalBytes = await resolver.GetTotalBytesAsync(version, CancellationToken.None); + + totalBytes.Should().BeNull(); + metadataReader.RequestCount.Should().Be(0); + manifestReader.RequestCount.Should().Be(0); + } + + [Fact] + public async Task GetTotalBytesAsync_MapsMetadataFailureToUnavailableAsync() + { + RecordingMetadataReader metadataReader = new((_, _) => + Task.FromException(new HttpRequestException("Offline"))); + RemotePackageSizeResolver resolver = CreateResolver(metadataReader, new RecordingManifestReader()); + LauncherContentVersion version = new() + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "009", + SimpleDownloadLink = "https://example.test/contra.zip", + }; + + long? totalBytes = await resolver.GetTotalBytesAsync(version, CancellationToken.None); + + totalBytes.Should().BeNull(); + } + + [Fact] + public async Task GetTotalBytesAsync_CachesByContentVersionAndSourceMetadataAsync() + { + RecordingMetadataReader metadataReader = new((uri, _) => + Task.FromResult(new DownloadFileMetadata(uri, "package.zip", 42))); + RemotePackageSizeResolver resolver = CreateResolver(metadataReader, new RecordingManifestReader()); + LauncherContentVersion firstVersion = new() + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "1.0", + SimpleDownloadLink = "https://example.test/contra.zip", + }; + LauncherContentVersion changedVersion = new() + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "2.0", + SimpleDownloadLink = "https://example.test/contra.zip", + }; + + await resolver.GetTotalBytesAsync(firstVersion, CancellationToken.None); + await resolver.GetTotalBytesAsync(firstVersion, CancellationToken.None); + await resolver.GetTotalBytesAsync(changedVersion, CancellationToken.None); + + metadataReader.RequestCount.Should().Be(2); + } + + private static RemotePackageSizeResolver CreateResolver( + IDownloadFileMetadataReader metadataReader, + IS3ObjectManifestReader manifestReader) + { + return new RemotePackageSizeResolver( + metadataReader, + manifestReader, + NullLogger.Instance); + } + + private static LauncherContentVersion CreateS3Version(string version) + { + return new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "Rise of the Reds", + Version = version, + S3HostLink = "https://s3.example.test", + S3BucketName = "mods", + S3FolderName = $"rotr/{version}", + }; + } + + private sealed class RecordingMetadataReader : IDownloadFileMetadataReader + { + private readonly Func>? _handler; + + public RecordingMetadataReader( + Func>? handler = null) + { + _handler = handler; + } + + public int RequestCount { get; private set; } + + public Task ReadMetadataAsync(Uri downloadUri, CancellationToken cancellationToken) + { + RequestCount++; + return _handler?.Invoke(downloadUri, cancellationToken) ?? + Task.FromException(new InvalidOperationException("Unexpected metadata read.")); + } + } + + private sealed class RecordingManifestReader : IS3ObjectManifestReader + { + private readonly Queue> _results; + + public RecordingManifestReader(params IReadOnlyList[] results) + { + _results = new Queue>(results); + } + + public int RequestCount { get; private set; } + + public Task> ReadManifestAsync( + S3ObjectManifestRequest request, + CancellationToken cancellationToken) + { + RequestCount++; + return _results.Count > 0 + ? Task.FromResult(_results.Dequeue()) + : Task.FromException>( + new InvalidOperationException("Unexpected manifest read.")); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.cs new file mode 100644 index 00000000..565b420b --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.cs @@ -0,0 +1,459 @@ +using System; +using System.Collections.Concurrent; +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 GenLauncherGO.Tests.Testing; +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(); + string latestPath = Path.Combine(testDirectory.Path, "Mods", "latest"); + string temporaryPath = Path.Combine(testDirectory.Path, "Staging", "temp"); + string installedPath = Path.Combine(testDirectory.Path, "Mods", "installed"); + string latestFilePath = Path.Combine(latestPath, "Data", "readme.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(latestFilePath)!); + await File.WriteAllTextAsync(latestFilePath, "payload"); + + string hash = "0123456789ABCDEF0123456789ABCDEF"; + RecordingFileDownloader downloader = new(); + StubFileHashService hashService = new() { HashForPath = _ => hash }; + S3PackageUpdater updater = CreateUpdater(downloader, hashService); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateRequest( + temporaryPath, + installedPath, + latestPath, + new RemoteFileManifestEntry("Data/readme.txt", hash, (ulong)new FileInfo(latestFilePath).Length)), + progress, + CancellationToken.None); + + downloader.Requests.Should().BeEmpty(); + File.ReadAllText(Path.Combine(installedPath, "Data", "readme.txt")).Should().Be("payload"); + progress.Reports.Should().Contain(report => report.FileName == null); + } + + [Fact] + public async Task UpdateAsync_ReusesInstalledGibVariantForBigManifestEntryAsync() + { + using TestDirectory testDirectory = new(); + string latestPath = Path.Combine(testDirectory.Path, "Mods", "latest"); + string temporaryPath = Path.Combine(testDirectory.Path, "Staging", "temp"); + string installedPath = Path.Combine(testDirectory.Path, "Mods", "installed"); + string latestFilePath = Path.Combine(latestPath, "Data", "archive.gib"); + Directory.CreateDirectory(Path.GetDirectoryName(latestFilePath)!); + await File.WriteAllTextAsync(latestFilePath, "payload"); + + const string Hash = "0123456789ABCDEF0123456789ABCDEF"; + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater( + downloader, + new StubFileHashService { HashForPath = _ => Hash }); + + await updater.UpdateAsync( + CreateRequest( + temporaryPath, + installedPath, + latestPath, + new RemoteFileManifestEntry( + "Data/archive.big", + Hash, + (ulong)new FileInfo(latestFilePath).Length)), + null, + CancellationToken.None); + + downloader.Requests.Should().BeEmpty(); + File.ReadAllText(Path.Combine(installedPath, "Data", "archive.gib")).Should().Be("payload"); + File.Exists(Path.Combine(installedPath, "Data", "archive.big")).Should().BeFalse(); + } + + [Fact] + public async Task UpdateAsync_ReportsOnlyMissingFileBytesWhenLatestFilesAreReusedAsync() + { + using TestDirectory testDirectory = new(); + string latestPath = Path.Combine(testDirectory.Path, "Mods", "latest"); + string temporaryPath = Path.Combine(testDirectory.Path, "Staging", "temp"); + string installedPath = Path.Combine(testDirectory.Path, "Mods", "installed"); + string latestFilePath = Path.Combine(latestPath, "Data", "reused.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(latestFilePath)!); + await File.WriteAllBytesAsync(latestFilePath, CreatePayload(660)); + + string hash = "0123456789ABCDEF0123456789ABCDEF"; + RecordingFileDownloader downloader = new(); + StubFileHashService hashService = new() { HashForPath = _ => hash }; + S3PackageUpdater updater = CreateUpdater(downloader, hashService); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateRequest( + temporaryPath, + installedPath, + latestPath, + new RemoteFileManifestEntry("Data/reused.txt", hash, 660), + new RemoteFileManifestEntry("Data/missing.txt", hash, 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_ReportsOnlyRemainingBytesForPartialStagedDownloadAsync() + { + using TestDirectory testDirectory = new(); + string temporaryPath = Path.Combine(testDirectory.Path, "Staging", "temp"); + string installedPath = Path.Combine(testDirectory.Path, "Mods", "installed"); + string partialFilePath = Path.Combine(temporaryPath, "Data", "missing.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(partialFilePath)!); + await File.WriteAllBytesAsync(partialFilePath, CreatePayload(5)); + + string hash = "0123456789ABCDEF0123456789ABCDEF"; + RecordingFileDownloader downloader = new(); + StubFileHashService hashService = new() { HashForPath = _ => hash }; + S3PackageUpdater updater = CreateUpdater(downloader, hashService); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateRequest( + temporaryPath, + installedPath, + null, + new RemoteFileManifestEntry("Data/missing.txt", hash, 20)), + progress, + CancellationToken.None); + + downloader.Requests.Should().ContainSingle(); + progress.Reports.Should().ContainSingle(); + progress.Reports[0].TotalBytes.Should().Be(15); + progress.Reports[0].BytesRead.Should().Be(15); + progress.Reports[0].ProgressPercentage.Should().Be(100); + } + + [Fact] + public async Task UpdateAsync_RejectsManifestPathOutsideTemporaryFolderAsync() + { + using TestDirectory testDirectory = new(); + S3PackageUpdater updater = CreateUpdater(new RecordingFileDownloader(), new StubFileHashService()); + + Func act = async () => await updater.UpdateAsync( + CreateRequest( + Path.Combine(testDirectory.Path, "Staging", "temp"), + Path.Combine(testDirectory.Path, "Mods", "installed"), + null, + new RemoteFileManifestEntry("../escape.txt", "0123456789ABCDEF0123456789ABCDEF", 1)), + null, + CancellationToken.None); + + await act.Should().ThrowAsync(); + File.Exists(Path.Combine(testDirectory.Path, "escape.txt")).Should().BeFalse(); + } + + [Fact] + public async Task UpdateAsync_PrunesStaleTemporaryFilesBeforeInstallingAsync() + { + using TestDirectory testDirectory = new(); + string temporaryRoot = Path.Combine(testDirectory.Path, "Runtime", "Temp"); + string packagesPath = Path.Combine(temporaryRoot, "Packages"); + string temporaryPath = Path.Combine(packagesPath, "NProject Mod", "2.11"); + string installedPath = Path.Combine(testDirectory.Path, "Mods", "installed"); + Directory.CreateDirectory(temporaryPath); + await File.WriteAllTextAsync(Path.Combine(temporaryPath, "stale.txt"), "stale"); + await File.WriteAllTextAsync(Path.Combine(temporaryPath, "readme.txt"), "payload"); + + string hash = "0123456789ABCDEF0123456789ABCDEF"; + S3PackageUpdater updater = CreateUpdater( + new RecordingFileDownloader(), + new StubFileHashService { HashForPath = _ => hash }); + + await updater.UpdateAsync( + CreateRequest( + temporaryPath, + installedPath, + null, + new RemoteFileManifestEntry("readme.txt", hash, 7)), + null, + CancellationToken.None); + + File.Exists(Path.Combine(installedPath, "stale.txt")).Should().BeFalse(); + File.ReadAllText(Path.Combine(installedPath, "readme.txt")).Should().Be("payload"); + Directory.Exists(packagesPath).Should().BeFalse(); + Directory.Exists(temporaryRoot).Should().BeTrue(); + } + + [SymbolicLinkFact] + public async Task UpdateAsync_RemovesUnsafeStagingLinkWithoutDeletingTargetAsync() + { + using TestDirectory testDirectory = new(); + string temporaryPath = Path.Combine(testDirectory.Path, "Staging", "temp"); + string installedPath = Path.Combine(testDirectory.Path, "Mods", "installed"); + string outsidePath = Path.Combine(testDirectory.Path, "outside"); + Directory.CreateDirectory(temporaryPath); + Directory.CreateDirectory(outsidePath); + await File.WriteAllTextAsync(Path.Combine(temporaryPath, "readme.txt"), "payload"); + string outsideFile = Path.Combine(outsidePath, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside"); + SymbolicLinkTestSupport.CreateDirectoryLink( + Path.Combine(temporaryPath, "linked"), + outsidePath); + + string hash = "0123456789ABCDEF0123456789ABCDEF"; + S3PackageUpdater updater = CreateUpdater( + new RecordingFileDownloader(), + new StubFileHashService { HashForPath = _ => hash }); + + await updater.UpdateAsync( + CreateRequest( + temporaryPath, + installedPath, + null, + new RemoteFileManifestEntry("readme.txt", hash, 7)), + null, + CancellationToken.None); + + Directory.Exists(Path.Combine(installedPath, "linked")).Should().BeFalse(); + File.ReadAllText(outsideFile).Should().Be("outside"); + } + + [Fact] + public async Task RepairFilesAsync_DownloadsSelectedModifiedFileInPlaceAsync() + { + using TestDirectory testDirectory = new(); + string installedPath = Path.Combine(testDirectory.Path, "Mods", "installed"); + string staleFilePath = Path.Combine(installedPath, "Data", "readme.txt"); + string keepFilePath = Path.Combine(installedPath, "Data", "keep.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(staleFilePath)!); + await File.WriteAllTextAsync(staleFilePath, "stale"); + await File.WriteAllTextAsync(keepFilePath, "keep"); + + string hash = "0123456789ABCDEF0123456789ABCDEF"; + RecordingFileDownloader downloader = new(); + StubFileHashService hashService = new() + { + HashForPath = path => File.ReadAllBytes(path).All(value => value == (byte)'x') ? hash : "BAD", + }; + S3PackageUpdater updater = CreateUpdater(downloader, hashService); + RecordingProgress progress = new(); + + await updater.RepairFilesAsync( + CreateRepairRequest( + installedPath, + new RemoteFileManifestEntry("Data/readme.txt", hash, 5)), + progress, + CancellationToken.None); + + DownloadFileRequest request = downloader.Requests.Should().ContainSingle().Which; + request.DestinationFilePath.Should().Be(staleFilePath); + File.ReadAllBytes(staleFilePath).Should().AllBeEquivalentTo((byte)'x'); + File.ReadAllText(keepFilePath).Should().Be("keep"); + progress.Reports.Should().ContainSingle(report => + report.TotalBytes == 5 && + report.BytesRead == 5 && + report.ProgressPercentage == 100); + } + + [Fact] + public async Task UpdateAsyncHashRetryReportsMonotonicProgressAndOnlyFinishesAtOneHundredAsync() + { + using TestDirectory testDirectory = new(); + string expectedHash = "0123456789ABCDEF0123456789ABCDEF"; + int hashAttempt = 0; + S3PackageUpdater updater = CreateUpdater( + new ProgressReportingFileDownloader(), + new StubFileHashService + { + HashForPath = _ => Interlocked.Increment(ref hashAttempt) == 1 + ? "BAD" + : expectedHash, + }); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateRequest( + Path.Combine(testDirectory.Path, "Staging", "temporary"), + Path.Combine(testDirectory.Path, "Mods", "installed"), + null, + new RemoteFileManifestEntry("Data/file.txt", expectedHash, 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 UpdateAsyncRejectsDuplicateNormalizedManifestDestinationsAsync( + string firstFile, + string secondFile) + { + using TestDirectory testDirectory = new(); + S3PackageUpdater updater = CreateUpdater( + new RecordingFileDownloader(), + new StubFileHashService()); + S3PackageUpdateRequest request = CreateRequest( + Path.Combine(testDirectory.Path, "Staging", "temporary"), + Path.Combine(testDirectory.Path, "Mods", "installed"), + null, + 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 S3PackageUpdateRequest CreateRequest( + string temporaryPath, + string installedPath, + string? latestPath, + params RemoteFileManifestEntry[] files) + { + string installedRoot = Path.GetDirectoryName(installedPath)!; + string ownedGameDataRoot = Path.GetDirectoryName(installedRoot)!; + string packageRoot = Path.Combine( + ownedGameDataRoot, + "Runtime", + "Temp", + LauncherFileSystemLayout.PackagesFolderName); + string temporaryOwnerRoot = temporaryPath.StartsWith( + packageRoot + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase) + ? packageRoot + : Path.Combine(ownedGameDataRoot, "Staging"); + string backupRoot = Path.Combine(ownedGameDataRoot, "Runtime", "State", "PackageBackups"); + string backupPath = Path.Combine( + backupRoot, + Path.GetRelativePath(installedRoot, installedPath)); + return new S3PackageUpdateRequest( + files, + CreateSource(), + new PackageUpdatePathSet( + new OwnedContentPath(temporaryOwnerRoot, temporaryPath), + new OwnedContentPath(installedRoot, installedPath), + new OwnedContentPath(backupRoot, backupPath), + latestPath is null ? null : new OwnedContentPath(installedRoot, latestPath)), + new HashSet(StringComparer.OrdinalIgnoreCase) { ".txt", ".big", ".gib" }); + } + + private static S3PackageFileRepairRequest CreateRepairRequest( + string installedPath, + params RemoteFileManifestEntry[] files) + { + return new S3PackageFileRepairRequest( + files, + CreateSource(), + new OwnedContentPath(Path.GetDirectoryName(installedPath)!, installedPath), + 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; + } + + private sealed class RecordingFileDownloader : IResumableFileDownloader + { + public ConcurrentQueue Requests { get; } = new(); + + public async Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + Requests.Enqueue(request); + long length = request.ExpectedBytes.GetValueOrDefault(); + byte[] payload = CreatePayload(checked((int)length)); + Directory.CreateDirectory(Path.GetDirectoryName(request.DestinationFilePath)!); + await File.WriteAllBytesAsync(request.DestinationFilePath, payload, cancellationToken); + } + } + + private sealed class StubFileHashService : IFileHashService + { + public Func HashForPath { get; init; } = _ => "0123456789ABCDEF0123456789ABCDEF"; + + public Task ComputeMd5HashAsync(string filePath, CancellationToken cancellationToken) + { + return Task.FromResult(HashForPath(filePath)); + } + } + + private sealed class ProgressReportingFileDownloader : IResumableFileDownloader + { + public async Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + long length = request.ExpectedBytes.GetValueOrDefault(); + byte[] payload = CreatePayload(checked((int)length)); + Directory.CreateDirectory(Path.GetDirectoryName(request.DestinationFilePath)!); + await File.WriteAllBytesAsync(request.DestinationFilePath, payload, cancellationToken); + progress?.Report(new DownloadProgress(length, length, 100)); + } + } + + private sealed class RecordingProgress : IProgress + { + private readonly List _reports = new(); + + public IReadOnlyList Reports => _reports; + + public void Report(PackageUpdateProgress value) + { + _reports.Add(value); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/SingleFilePackageUpdaterTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/SingleFilePackageUpdaterTests.cs new file mode 100644 index 00000000..de25ce1f --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/SingleFilePackageUpdaterTests.cs @@ -0,0 +1,292 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Archives.Contracts; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class SingleFilePackageUpdaterTests +{ + [Fact] + public async Task UpdateAsync_ClearsStaleTemporaryFilesBeforeInstallingAsync() + { + using TestDirectory testDirectory = new(); + string temporaryPath = Path.Combine(testDirectory.Path, "Staging", "temp"); + string installedPath = Path.Combine(testDirectory.Path, "Mods", "installed"); + Directory.CreateDirectory(temporaryPath); + await File.WriteAllTextAsync(Path.Combine(temporaryPath, "stale.txt"), "stale"); + + SingleFilePackageUpdater updater = new( + new WritingFileDownloader("payload"), + new StubMetadataReader("readme.txt", 7), + new ThrowingArchiveExtractor(), + NullLogger.Instance); + + (Uri SourceUri, PackageUpdatePathSet Paths) request = CreateRequest( + new Uri("https://example.test/readme.txt"), + testDirectory.Path, + temporaryPath, + installedPath); + await updater.UpdateAsync( + request.SourceUri, + request.Paths, + null, + CancellationToken.None); + + File.Exists(Path.Combine(installedPath, "stale.txt")).Should().BeFalse(); + File.ReadAllText(Path.Combine(installedPath, "readme.txt")).Should().Be("payload"); + } + + [Fact] + public async Task UpdateAsync_RemovesEmptyPackageStagingParentsAfterInstallingAsync() + { + using TestDirectory testDirectory = new(); + string temporaryRoot = Path.Combine(testDirectory.Path, "Runtime", "Temp"); + string packagesPath = Path.Combine(temporaryRoot, "Packages"); + string temporaryPath = Path.Combine(packagesPath, "NProject Mod", "2.11"); + string installedPath = Path.Combine(testDirectory.Path, "Mods", "installed"); + + SingleFilePackageUpdater updater = new( + new WritingFileDownloader("payload"), + new StubMetadataReader("readme.txt", 7), + new ThrowingArchiveExtractor(), + NullLogger.Instance); + + (Uri SourceUri, PackageUpdatePathSet Paths) request = CreateRequest( + new Uri("https://example.test/readme.txt"), + testDirectory.Path, + temporaryPath, + installedPath); + await updater.UpdateAsync( + request.SourceUri, + request.Paths, + null, + CancellationToken.None); + + File.ReadAllText(Path.Combine(installedPath, "readme.txt")).Should().Be("payload"); + Directory.Exists(packagesPath).Should().BeFalse(); + Directory.Exists(temporaryRoot).Should().BeTrue(); + } + + [Theory] + [InlineData(".zip")] + [InlineData(".rar")] + [InlineData(".7z")] + public async Task UpdateAsyncExtractsArchiveDeletesDownloadedArchiveAndInstallsExtractedFilesAsync( + string extension) + { + using TestDirectory testDirectory = new(); + string temporaryPath = Path.Combine(testDirectory.Path, "Staging", "temp"); + string installedPath = Path.Combine(testDirectory.Path, "Mods", "installed"); + string archiveFileName = "package" + extension; + var archiveExtractor = new RecordingArchiveExtractor("extracted.gib", "extracted"); + + SingleFilePackageUpdater updater = new( + new WritingFileDownloader("archive"), + new StubMetadataReader(archiveFileName, 7), + archiveExtractor, + NullLogger.Instance); + + (Uri SourceUri, PackageUpdatePathSet Paths) request = CreateRequest( + new Uri("https://example.test/" + archiveFileName), + testDirectory.Path, + temporaryPath, + installedPath); + await updater.UpdateAsync( + request.SourceUri, + request.Paths, + null, + CancellationToken.None); + + File.Exists(Path.Combine(installedPath, archiveFileName)).Should().BeFalse(); + File.ReadAllText(Path.Combine(installedPath, "extracted.gib")).Should().Be("extracted"); + archiveExtractor.ArchiveFileName.Should().Be(archiveFileName); + archiveExtractor.ConvertBigFilesToGib.Should().BeTrue(); + } + + [Theory] + [InlineData(".")] + [InlineData("..")] + [InlineData("../escape.zip")] + [InlineData(@"nested\escape.zip")] + [InlineData("nested/escape.zip")] + [InlineData("C:escape.zip")] + public async Task UpdateAsyncRejectsUnsafeRemoteMetadataFileNameAsync(string fileName) + { + using TestDirectory testDirectory = new(); + string temporaryPath = Path.Combine(testDirectory.Path, "Staging", "temp"); + string installedPath = Path.Combine(testDirectory.Path, "Mods", "installed"); + SingleFilePackageUpdater updater = new( + new WritingFileDownloader("payload"), + new StubMetadataReader(fileName, 7), + new ThrowingArchiveExtractor(), + NullLogger.Instance); + + (Uri SourceUri, PackageUpdatePathSet Paths) request = CreateRequest( + new Uri("https://example.test/package.zip"), + testDirectory.Path, + temporaryPath, + installedPath); + Func update = () => updater.UpdateAsync( + request.SourceUri, + request.Paths, + null, + CancellationToken.None); + + await update.Should().ThrowAsync() + .WithMessage("*safe direct file name*"); + Directory.Exists(installedPath).Should().BeFalse(); + File.Exists(Path.Combine(testDirectory.Path, "escape.zip")).Should().BeFalse(); + } + + [SymbolicLinkFact] + public async Task UpdateAsyncRejectsLinkedInstalledRootWithoutDeletingTargetAsync() + { + using TestDirectory testDirectory = new(); + string temporaryPath = Path.Combine(testDirectory.Path, "Staging", "temp"); + string installedPath = Path.Combine(testDirectory.Path, "Mods", "installed"); + string outsidePath = Path.Combine(testDirectory.Path, "outside"); + Directory.CreateDirectory(outsidePath); + Directory.CreateDirectory(Path.GetDirectoryName(installedPath)!); + string outsideFile = Path.Combine(outsidePath, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside"); + SymbolicLinkTestSupport.CreateDirectoryLink(installedPath, outsidePath); + + SingleFilePackageUpdater updater = new( + new WritingFileDownloader("payload"), + new StubMetadataReader("readme.txt", 7), + new ThrowingArchiveExtractor(), + NullLogger.Instance); + + (Uri SourceUri, PackageUpdatePathSet Paths) request = CreateRequest( + new Uri("https://example.test/readme.txt"), + testDirectory.Path, + temporaryPath, + installedPath); + Func update = () => updater.UpdateAsync( + request.SourceUri, + request.Paths, + null, + CancellationToken.None); + + await update.Should().ThrowAsync(); + File.ReadAllText(outsideFile).Should().Be("outside"); + } + + private static (Uri SourceUri, PackageUpdatePathSet Paths) CreateRequest( + Uri sourceUri, + string ownerRoot, + string temporaryPath, + string installedPath) + { + string packageRoot = Path.Combine( + ownerRoot, + "Runtime", + "Temp", + LauncherFileSystemLayout.PackagesFolderName); + string temporaryOwnerRoot = temporaryPath.StartsWith( + packageRoot + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase) + ? packageRoot + : Path.Combine(ownerRoot, "Staging"); + string installedOwnerRoot = Path.Combine(ownerRoot, "Mods"); + string backupRoot = Path.Combine(ownerRoot, "Runtime", "State", "PackageBackups"); + string backupPath = Path.Combine( + backupRoot, + Path.GetRelativePath(installedOwnerRoot, installedPath)); + return ( + sourceUri, + new PackageUpdatePathSet( + new OwnedContentPath(temporaryOwnerRoot, temporaryPath), + new OwnedContentPath(installedOwnerRoot, installedPath), + new OwnedContentPath(backupRoot, backupPath))); + } + + private sealed class WritingFileDownloader : IResumableFileDownloader + { + private readonly string _contents; + + public WritingFileDownloader(string contents) + { + _contents = contents; + } + + public async Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + await File.WriteAllTextAsync(request.DestinationFilePath, _contents, cancellationToken); + progress?.Report(new DownloadProgress(request.ExpectedBytes, _contents.Length, 100)); + } + } + + private sealed class StubMetadataReader : IDownloadFileMetadataReader + { + private readonly string _fileName; + private readonly long _totalBytes; + + public StubMetadataReader(string fileName, long totalBytes) + { + _fileName = fileName; + _totalBytes = totalBytes; + } + + public Task ReadMetadataAsync( + Uri downloadUri, + CancellationToken cancellationToken) + { + return Task.FromResult(new DownloadFileMetadata(downloadUri, _fileName, _totalBytes)); + } + } + + private sealed class ThrowingArchiveExtractor : IArchiveExtractor + { + public void ExtractToDirectory( + string archiveFilePath, + string destinationDirectory, + bool convertBigFilesToGib = false, + CancellationToken cancellationToken = default) + { + throw new InvalidOperationException("Extraction should not be used for non-archive files."); + } + } + + private sealed class RecordingArchiveExtractor : IArchiveExtractor + { + private readonly string _extractedFileName; + private readonly string _extractedContents; + + public RecordingArchiveExtractor(string extractedFileName, string extractedContents) + { + _extractedFileName = extractedFileName; + _extractedContents = extractedContents; + } + + public string? ArchiveFileName { get; private set; } + + public bool? ConvertBigFilesToGib { get; private set; } + + public void ExtractToDirectory( + string archiveFilePath, + string destinationDirectory, + bool convertBigFilesToGib = false, + CancellationToken cancellationToken = default) + { + ArchiveFileName = Path.GetFileName(archiveFilePath); + ConvertBigFilesToGib = convertBigFilesToGib; + File.WriteAllText( + Path.Combine(destinationDirectory, _extractedFileName), + _extractedContents); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/DownloadLinkResolverTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/DownloadLinkResolverTests.cs new file mode 100644 index 00000000..4ec398df --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/DownloadLinkResolverTests.cs @@ -0,0 +1,39 @@ +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class DownloadLinkResolverTests +{ + [Fact] + public void ResolveDirectDownloadLink_ConvertsDropboxPreviewLinkToDownloadLink() + { + const string link = "https://www.dropbox.com/s/example/Package.7z?dl=0"; + + string resolved = DownloadLinkResolver.ResolveDirectDownloadLink(link); + + resolved.Should().Be("https://www.dropbox.com/s/example/Package.7z?dl=1"); + } + + [Fact] + public void ResolveDirectDownloadLink_ConvertsOneDriveEmbedLinkToDownloadLink() + { + const string link = "https://onedrive.live.com/embed?cid=abc&resid=abc%211"; + + string resolved = DownloadLinkResolver.ResolveDirectDownloadLink(link); + + resolved.Should().Be("https://onedrive.live.com/download?cid=abc&resid=abc%211"); + } + + [Fact] + public void ResolveDirectDownloadLink_ConvertsOneDriveShareLinkToDownloadLink() + { + const string link = + "https://onedrive.live.com/?authkey=%21key&cid=896C9369E9176506&id=896C9369E9176506%21464&parId=896C9369E9176506%21463&o=OneUp"; + + string resolved = DownloadLinkResolver.ResolveDirectDownloadLink(link); + + resolved.Should() + .Be("https://onedrive.live.com/download?cid=896C9369E9176506&resid=896C9369E9176506%21464&authkey=%21key"); + } + +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageInstallFolderReplacerTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageInstallFolderReplacerTests.cs new file mode 100644 index 00000000..d809204d --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageInstallFolderReplacerTests.cs @@ -0,0 +1,300 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Updating.Support; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class PackageInstallFolderReplacerTests +{ + [Fact] + public void ReplaceMovesTemporaryFolderIntoNewInstalledLocation() + { + using TestDirectory testDirectory = new(); + string temporaryFolder = Path.Combine(testDirectory.Path, "staging", "Mod", "1.0"); + string installedFolder = Path.Combine(testDirectory.Path, "installed", "Mod", "1.0"); + Directory.CreateDirectory(temporaryFolder); + File.WriteAllText(Path.Combine(temporaryFolder, "asset.txt"), "new"); + + PackageInstallFolderReplacer.Replace( + Own(testDirectory.Path, temporaryFolder), + Own(testDirectory.Path, installedFolder), + GetBackupPath(testDirectory, installedFolder), + NullLogger.Instance); + + Directory.Exists(temporaryFolder).Should().BeFalse(); + File.ReadAllText(Path.Combine(installedFolder, "asset.txt")).Should().Be("new"); + } + + [Fact] + public void ReplaceMovesExistingInstallToBackupBeforeReplacingIt() + { + using TestDirectory testDirectory = new(); + string temporaryFolder = Path.Combine(testDirectory.Path, "staging", "Mod", "1.0"); + string installedFolder = Path.Combine(testDirectory.Path, "installed", "Mod", "1.0"); + Directory.CreateDirectory(temporaryFolder); + Directory.CreateDirectory(installedFolder); + File.WriteAllText(Path.Combine(temporaryFolder, "asset.txt"), "new"); + File.WriteAllText(Path.Combine(installedFolder, "asset.txt"), "old"); + + PackageInstallFolderReplacer.Replace( + Own(testDirectory.Path, temporaryFolder), + Own(testDirectory.Path, installedFolder), + GetBackupPath(testDirectory, installedFolder), + NullLogger.Instance); + + File.ReadAllText(Path.Combine(installedFolder, "asset.txt")).Should().Be("new"); + Directory.Exists(GetBackupPath(testDirectory, installedFolder).FullPath).Should().BeFalse(); + } + + [Fact] + public void ReplaceThrowsWhenTemporaryFolderDoesNotExist() + { + using TestDirectory testDirectory = new(); + string temporaryFolder = Path.Combine(testDirectory.Path, "missing"); + string installedFolder = Path.Combine(testDirectory.Path, "installed"); + + Action act = () => PackageInstallFolderReplacer.Replace( + Own(testDirectory.Path, temporaryFolder), + Own(testDirectory.Path, installedFolder), + GetBackupPath(testDirectory, installedFolder), + NullLogger.Instance); + + act.Should().Throw() + .WithMessage("*Temporary package folder*"); + } + + [SymbolicLinkFact] + public void ReplaceThrowsWhenTemporaryTreeContainsReparsePoint() + { + using TestDirectory testDirectory = new(); + string temporaryFolder = Path.Combine(testDirectory.Path, "staging", "Mod", "1.0"); + string installedFolder = Path.Combine(testDirectory.Path, "installed", "Mod", "1.0"); + string linkTarget = Path.Combine(testDirectory.Path, "linked-target"); + string linkPath = Path.Combine(temporaryFolder, "Linked"); + Directory.CreateDirectory(temporaryFolder); + Directory.CreateDirectory(linkTarget); + File.WriteAllText(Path.Combine(temporaryFolder, "asset.txt"), "new"); + SymbolicLinkTestSupport.CreateDirectoryLink(linkPath, linkTarget); + + Action act = () => PackageInstallFolderReplacer.Replace( + Own(testDirectory.Path, temporaryFolder), + Own(testDirectory.Path, installedFolder), + GetBackupPath(testDirectory, installedFolder), + NullLogger.Instance); + + act.Should().Throw() + .WithMessage("*reparse point*"); + Directory.Exists(temporaryFolder).Should().BeTrue(); + Directory.Exists(installedFolder).Should().BeFalse(); + } + + [SymbolicLinkFact] + public void ReplaceThrowsWhenInstalledPathChainContainsReparsePoint() + { + using TestDirectory testDirectory = new(); + string temporaryFolder = Path.Combine(testDirectory.Path, "staging", "Mod", "1.0"); + string realInstalledRoot = Path.Combine(testDirectory.Path, "real-installed"); + string linkedInstalledRoot = Path.Combine(testDirectory.Path, "installed-link"); + string installedFolder = Path.Combine(linkedInstalledRoot, "Mod", "1.0"); + Directory.CreateDirectory(temporaryFolder); + Directory.CreateDirectory(realInstalledRoot); + File.WriteAllText(Path.Combine(temporaryFolder, "asset.txt"), "new"); + SymbolicLinkTestSupport.CreateDirectoryLink(linkedInstalledRoot, realInstalledRoot); + + Action act = () => PackageInstallFolderReplacer.Replace( + Own(testDirectory.Path, temporaryFolder), + Own(testDirectory.Path, installedFolder), + GetBackupPath(testDirectory, installedFolder), + NullLogger.Instance); + + act.Should().Throw() + .WithMessage("*reparse point*"); + Directory.Exists(temporaryFolder).Should().BeTrue(); + File.Exists(Path.Combine(realInstalledRoot, "Mod", "1.0", "asset.txt")).Should().BeFalse(); + } + + [Fact] + public void ReplaceRestoresExistingInstallWhenReplacementMoveFails() + { + using TestDirectory testDirectory = new(); + string installedFolder = Path.Combine(testDirectory.Path, "installed", "Mod", "1.0"); + Directory.CreateDirectory(installedFolder); + File.WriteAllText(Path.Combine(installedFolder, "asset.txt"), "old"); + + Action act = () => PackageInstallFolderReplacer.Replace( + Own(testDirectory.Path, installedFolder), + Own(testDirectory.Path, installedFolder), + GetBackupPath(testDirectory, installedFolder), + NullLogger.Instance); + + act.Should().Throw(); + File.ReadAllText(Path.Combine(installedFolder, "asset.txt")).Should().Be("old"); + Directory.Exists(GetBackupPath(testDirectory, installedFolder).FullPath).Should().BeFalse(); + } + + [Fact] + public void ReplaceLeavesLegitimateSiblingVersionNamedBackupUntouched() + { + using TestDirectory testDirectory = new(); + string temporaryFolder = Path.Combine(testDirectory.Path, "staging", "Mod", "1.0"); + string installedFolder = Path.Combine(testDirectory.Path, "installed", "Mod", "1.0"); + string legitimateSibling = Path.Combine(testDirectory.Path, "installed", "Mod", "1.0.backup"); + Directory.CreateDirectory(temporaryFolder); + Directory.CreateDirectory(installedFolder); + Directory.CreateDirectory(legitimateSibling); + File.WriteAllText(Path.Combine(temporaryFolder, "asset.txt"), "new"); + File.WriteAllText(Path.Combine(installedFolder, "asset.txt"), "old"); + File.WriteAllText(Path.Combine(legitimateSibling, "asset.txt"), "legitimate"); + + OwnedContentPath recoveryBackup = GetBackupPath(testDirectory, installedFolder); + PackageInstallFolderReplacer.Replace( + Own(testDirectory.Path, temporaryFolder), + Own(testDirectory.Path, installedFolder), + recoveryBackup, + NullLogger.Instance); + + File.ReadAllText(Path.Combine(installedFolder, "asset.txt")).Should().Be("new"); + File.ReadAllText(Path.Combine(legitimateSibling, "asset.txt")).Should().Be("legitimate"); + Directory.Exists(recoveryBackup.FullPath).Should().BeFalse(); + } + + [Fact] + public void ReplaceRejectsRecoveryPathInsideInstalledContentBeforeMutation() + { + using TestDirectory testDirectory = new(); + string temporaryFolder = Path.Combine(testDirectory.Path, "staging", "Mod", "1.0"); + string installedFolder = Path.Combine(testDirectory.Path, "installed", "Mod", "1.0"); + string overlappingBackup = Path.Combine(installedFolder, "recovery"); + Directory.CreateDirectory(temporaryFolder); + Directory.CreateDirectory(installedFolder); + File.WriteAllText(Path.Combine(temporaryFolder, "asset.txt"), "new"); + File.WriteAllText(Path.Combine(installedFolder, "asset.txt"), "old"); + + Action act = () => PackageInstallFolderReplacer.Replace( + Own(testDirectory.Path, temporaryFolder), + Own(testDirectory.Path, installedFolder), + Own(testDirectory.Path, overlappingBackup), + NullLogger.Instance); + + act.Should().Throw() + .WithParameterName("backupPath"); + File.ReadAllText(Path.Combine(installedFolder, "asset.txt")).Should().Be("old"); + Directory.Exists(temporaryFolder).Should().BeTrue(); + } + + [Fact] + public void ReplaceKeepsCommittedInstallAndDurableRecoveryBackupWhenPostCommitCleanupFails() + { + using TestDirectory testDirectory = new(); + string temporaryRoot = Path.Combine(testDirectory.Path, "Temp", "Packages"); + string installedRoot = Path.Combine(testDirectory.Path, "Launcher", "Mods"); + string temporaryFolder = Path.Combine(temporaryRoot, "Mod", "1.0"); + string installedFolder = Path.Combine(installedRoot, "Mod", "1.0"); + Directory.CreateDirectory(temporaryFolder); + Directory.CreateDirectory(installedFolder); + File.WriteAllText(Path.Combine(temporaryFolder, "asset.txt"), "new"); + File.WriteAllText(Path.Combine(installedFolder, "asset.txt"), "old"); + var temporaryPath = new OwnedContentPath(temporaryRoot, temporaryFolder); + var installedPath = new OwnedContentPath(installedRoot, installedFolder); + OwnedContentPath backupPath = GetBackupPath(testDirectory, installedFolder); + + PackageInstallFolderReplacer.Replace( + temporaryPath, + installedPath, + backupPath, + NullLogger.Instance, + _ => throw new IOException("cleanup failed")); + + File.ReadAllText(Path.Combine(installedFolder, "asset.txt")).Should().Be("new"); + File.ReadAllText(Path.Combine(backupPath.FullPath, "asset.txt")).Should().Be("old"); + } + + [Fact] + public void ReplaceRestoresInterruptedBackupBeforeRejectingMissingStagingFolder() + { + using TestDirectory testDirectory = new(); + string temporaryFolder = Path.Combine(testDirectory.Path, "staging", "Mod", "1.0"); + string installedFolder = Path.Combine(testDirectory.Path, "installed", "Mod", "1.0"); + OwnedContentPath recoveryBackup = GetBackupPath(testDirectory, installedFolder); + Directory.CreateDirectory(recoveryBackup.FullPath); + File.WriteAllText(Path.Combine(recoveryBackup.FullPath, "asset.txt"), "old"); + + Action act = () => PackageInstallFolderReplacer.Replace( + Own(testDirectory.Path, temporaryFolder), + Own(testDirectory.Path, installedFolder), + GetBackupPath(testDirectory, installedFolder), + NullLogger.Instance); + + act.Should().Throw(); + File.ReadAllText(Path.Combine(installedFolder, "asset.txt")).Should().Be("old"); + Directory.Exists(recoveryBackup.FullPath).Should().BeFalse(); + } + + [Fact] + public void ReplaceReconcilesCommittedBackupBeforeStartingNextReplacement() + { + using TestDirectory testDirectory = new(); + string temporaryFolder = Path.Combine(testDirectory.Path, "staging", "Mod", "1.0"); + string installedFolder = Path.Combine(testDirectory.Path, "installed", "Mod", "1.0"); + OwnedContentPath recoveryBackup = GetBackupPath(testDirectory, installedFolder); + Directory.CreateDirectory(temporaryFolder); + Directory.CreateDirectory(installedFolder); + Directory.CreateDirectory(recoveryBackup.FullPath); + File.WriteAllText(Path.Combine(temporaryFolder, "asset.txt"), "next"); + File.WriteAllText(Path.Combine(installedFolder, "asset.txt"), "current"); + File.WriteAllText(Path.Combine(recoveryBackup.FullPath, "asset.txt"), "old"); + + PackageInstallFolderReplacer.Replace( + Own(testDirectory.Path, temporaryFolder), + Own(testDirectory.Path, installedFolder), + GetBackupPath(testDirectory, installedFolder), + NullLogger.Instance); + + File.ReadAllText(Path.Combine(installedFolder, "asset.txt")).Should().Be("next"); + Directory.Exists(recoveryBackup.FullPath).Should().BeFalse(); + } + + [SymbolicLinkFact] + public void ReplaceRejectsLinkedRecoveryBackup() + { + using TestDirectory testDirectory = new(); + string temporaryFolder = Path.Combine(testDirectory.Path, "staging", "Mod", "1.0"); + string installedFolder = Path.Combine(testDirectory.Path, "installed", "Mod", "1.0"); + OwnedContentPath recoveryBackup = GetBackupPath(testDirectory, installedFolder); + string linkTarget = Path.Combine(testDirectory.Path, "outside-backup"); + Directory.CreateDirectory(temporaryFolder); + Directory.CreateDirectory(installedFolder); + Directory.CreateDirectory(linkTarget); + File.WriteAllText(Path.Combine(linkTarget, "asset.txt"), "outside"); + Directory.CreateDirectory(Path.GetDirectoryName(recoveryBackup.FullPath)!); + SymbolicLinkTestSupport.CreateDirectoryLink(recoveryBackup.FullPath, linkTarget); + + Action act = () => PackageInstallFolderReplacer.Replace( + Own(testDirectory.Path, temporaryFolder), + Own(testDirectory.Path, installedFolder), + GetBackupPath(testDirectory, installedFolder), + NullLogger.Instance); + + act.Should().Throw() + .WithMessage("*reparse point*"); + File.ReadAllText(Path.Combine(linkTarget, "asset.txt")).Should().Be("outside"); + Directory.Exists(temporaryFolder).Should().BeTrue(); + } + + private static OwnedContentPath Own(string ownerRoot, string fullPath) + { + return new OwnedContentPath(ownerRoot, fullPath); + } + + private static OwnedContentPath GetBackupPath(TestDirectory testDirectory, string installedPath) + { + string backupRoot = Path.Combine(testDirectory.Path, "Runtime", "State", "PackageBackups"); + string backupPath = Path.Combine( + backupRoot, + Path.GetRelativePath(testDirectory.Path, installedPath)); + return new OwnedContentPath(backupRoot, backupPath); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageProgressTrackerTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageProgressTrackerTests.cs new file mode 100644 index 00000000..04e99cee --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageProgressTrackerTests.cs @@ -0,0 +1,100 @@ +using System; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; +using GenLauncherGO.Tests.Testing; + +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); + } + + [Fact] + public void Update_ThrottlesRepeatedReportsUntilForced() + { + PackageProgressTracker tracker = new(100); + + tracker.Update("file-a", 10); + PackageUpdateProgress? throttledProgress = tracker.Update("file-a", 20); + PackageUpdateProgress? forcedProgress = tracker.Update("file-a", 20, true); + + throttledProgress.Should().BeNull(); + forcedProgress.Should().NotBeNull(); + } + + [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.Advance(TimeSpan.FromMilliseconds(300)); + PackageUpdateProgress? progress = tracker.Update("file-a", 50, true); + + progress.Should().NotBeNull(); + progress!.DownloadSpeedBytesPerSecond.Should().BeGreaterThan(0); + progress.EstimatedTimeRemaining.Should().NotBeNull(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageStagingFolderCleanerTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageStagingFolderCleanerTests.cs new file mode 100644 index 00000000..fff6b634 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageStagingFolderCleanerTests.cs @@ -0,0 +1,175 @@ +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 GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class PackageStagingFolderCleanerTests +{ + [Fact] + public void ClearDirectoryCreatesStagingFolderAndDeletesExistingChildren() + { + 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 DeleteEmptyPackageParentsRemovesEmptyChainThroughPackagesFolder() + { + 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 DeleteEmptyPackageParentsStopsWhenParentContainsOtherEntries() + { + 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 DeleteEmptyPackageParentsReturnsWhenPathIsNotUnderPackagesFolder() + { + 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 DeleteEmptyPackageParentsReturnsWhenPackagesAncestorDoesNotExist() + { + 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 RemoveUnsafeLinksHonorsPreCanceledToken() + { + 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 RemoveUnsafeLinksRecursesThroughOrdinaryDirectories() + { + 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 PruneToManifestDeletesStaleFilesAndKeepsConvertedBigFiles() + { + 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(emptyFolder); + 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"); + } + + 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..a8bda7a6 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/S3HashValidationPolicyTests.cs @@ -0,0 +1,79 @@ +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 IsReliableMd5HashReturnsTrueForPlainHexMd5(string hash) + { + bool result = S3HashValidationPolicy.IsReliableMd5Hash(hash); + + result.Should().BeTrue(); + } + + [Theory] + [InlineData("")] + [InlineData("0123456789abcdef0123456789abcde")] + [InlineData("0123456789abcdef0123456789abcdef-2")] + [InlineData("0123456789abcdef0123456789abcdeg")] + public void IsReliableMd5HashReturnsFalseForMultipartOrMalformedHashes(string hash) + { + bool result = S3HashValidationPolicy.IsReliableMd5Hash(hash); + + result.Should().BeFalse(); + } + + [Fact] + public void ShouldCheckHashReturnsTrueWhenExtensionRequiresReliableMd5Validation() + { + RemoteFileManifestEntry file = new( + "Data/asset.big", + "0123456789abcdef0123456789abcdef", + 10); + HashSet hashCheckedExtensions = new( + new[] { ".big" }, + System.StringComparer.OrdinalIgnoreCase); + + bool result = S3HashValidationPolicy.ShouldCheckHash(file, hashCheckedExtensions); + + result.Should().BeTrue(); + } + + [Fact] + public void ShouldCheckHashReturnsFalseForUncheckedExtension() + { + RemoteFileManifestEntry file = new( + "Data/readme.txt", + "0123456789abcdef0123456789abcdef", + 10); + HashSet hashCheckedExtensions = new( + new[] { ".big" }, + System.StringComparer.OrdinalIgnoreCase); + + bool result = S3HashValidationPolicy.ShouldCheckHash(file, hashCheckedExtensions); + + result.Should().BeFalse(); + } + + [Fact] + public void ShouldCheckHashReturnsFalseForUnreliableHash() + { + RemoteFileManifestEntry file = new( + "Data/asset.big", + "0123456789abcdef0123456789abcdef-2", + 10); + HashSet hashCheckedExtensions = new( + new[] { ".big" }, + System.StringComparer.OrdinalIgnoreCase); + + bool result = S3HashValidationPolicy.ShouldCheckHash(file, hashCheckedExtensions); + + result.Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/Testing/FakeLauncherContentCatalog.cs b/GenLauncherGO.Tests/Testing/FakeLauncherContentCatalog.cs new file mode 100644 index 00000000..7d6c6777 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/FakeLauncherContentCatalog.cs @@ -0,0 +1,166 @@ +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 = new(); + + public LauncherData Data { get; set; } = new(); + + public LauncherContentVersion? Advertising { get; set; } + + public IReadOnlyList? RepositoryModificationNames { get; set; } + + public List InitializationRequests { get; } = new(); + + public List DownloadRequests { get; } = new(); + + public List MetadataRequests { get; } = new(); + + public List ChildManifestRequests { get; } = new(); + + public List UninstalledVersions { get; } = new(); + + public List DiscardedVersions { get; } = new(); + + public List DiscardedContents { get; } = new(); + + 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? DiscardVersionHandler { get; set; } + + public Action? DiscardContentHandler { get; set; } + + public Action? LocalDataUpdateHandler { get; set; } + + public Action? SaveHandler { 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) + { + MetadataRequests.Add(name); + 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 is + ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile) + { + 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); + DiscardVersionHandler?.Invoke(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)); + DiscardContentHandler?.Invoke(contentKey); + UpdateLocalModificationsData(); + } + + public void UpdateLocalModificationsData() + { + LocalDataUpdateCount++; + LocalDataUpdateHandler?.Invoke(); + } + + public void SaveLauncherData() + { + SaveCount++; + SaveHandler?.Invoke(); + } +} diff --git a/GenLauncherGO.Tests/Testing/ManualTimeProvider.cs b/GenLauncherGO.Tests/Testing/ManualTimeProvider.cs new file mode 100644 index 00000000..112f8b38 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/ManualTimeProvider.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class ManualTimeProvider : TimeProvider +{ + private long _timestamp; + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override long GetTimestamp() + { + return 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..c31f6053 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/QueueHttpMessageHandler.cs @@ -0,0 +1,35 @@ +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; } = new(); + + public void Enqueue(Func responseFactory) + { + ArgumentNullException.ThrowIfNull(responseFactory); + + _responses.Enqueue(responseFactory); + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(_responses.Dequeue()(request)); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingAtomicFileWriter.cs b/GenLauncherGO.Tests/Testing/RecordingAtomicFileWriter.cs new file mode 100644 index 00000000..4d8d16b1 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingAtomicFileWriter.cs @@ -0,0 +1,39 @@ +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) + { + DestinationPath = destinationPath; + Contents = contents; + } + + 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; + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingLocalLauncherContentService.cs b/GenLauncherGO.Tests/Testing/RecordingLocalLauncherContentService.cs new file mode 100644 index 00000000..43a2e37a --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingLocalLauncherContentService.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +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; } = new(); + + public List<(LauncherPaths Paths, LauncherContentKey ContentKey)> DeletedContents { get; } = new(); + + public List<( + LauncherPaths Paths, + LauncherContentKey ContentKey, + LauncherData Data)> ImageDeletionRequests + { get; } = new(); + + public IReadOnlyList FindInstalledVersions(LauncherPaths paths) + { + return InstalledVersions; + } + + 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)); + } +} 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/StaTestRunner.cs b/GenLauncherGO.Tests/Testing/StaTestRunner.cs new file mode 100644 index 00000000..19bd4924 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StaTestRunner.cs @@ -0,0 +1,56 @@ +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 object _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); + + 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..3a2dfed0 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StaTestRunnerTests.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; + +namespace GenLauncherGO.Tests.Testing; + +public sealed class StaTestRunnerTests +{ + [Fact] + public void RunExecutesActionOnAvaloniaUiThread() + { + StaTestRunner.Run(() => + { + Dispatcher.UIThread.CheckAccess().Should().BeTrue(); + }); + } + + [Fact] + public void RunPumpsAvaloniaDispatcherAndPreservesAffinityAcrossAwait() + { + 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; + + SynchronizationContext.Current.Should().NotBeNull(); + Dispatcher.UIThread.CheckAccess().Should().BeTrue(); + callbackThreadId.Should().Be(dispatcherThreadId); + Environment.CurrentManagedThreadId.Should().Be(dispatcherThreadId); + }); + } +} diff --git a/GenLauncherGO.Tests/Testing/StubLauncherContentStateStore.cs b/GenLauncherGO.Tests/Testing/StubLauncherContentStateStore.cs new file mode 100644 index 00000000..0a7cf873 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StubLauncherContentStateStore.cs @@ -0,0 +1,40 @@ +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 StubLauncherContentStateStore : ILauncherContentStateStore +{ + public LauncherContentState StateToLoad { get; set; } = new(); + + public int LoadCallCount { get; private set; } + + public Dictionary StatesToLoadByGame { get; } = new(); + + public List LoadedPaths { get; } = new(); + + public List SavedStates { get; } = new(); + + public List SavedPaths { get; } = new(); + + public Action? SaveHandler { get; set; } + + public LauncherContentState Load(LauncherPaths paths) + { + LoadCallCount++; + 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/StubRemoteYamlDocumentReader.cs b/GenLauncherGO.Tests/Testing/StubRemoteYamlDocumentReader.cs new file mode 100644 index 00000000..b415da25 --- /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 object _sync = new(); + + private readonly Dictionary<(Type DocumentType, Uri DocumentUri), int> _readCounts = new(); + + private readonly Dictionary< + (Type DocumentType, Uri DocumentUri), + Func>> _readHandlers = new(); + + 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) => + (object)(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; + } + } + + 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); + } + + 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..4dfef570 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/SymbolicLinkFactAttribute.cs @@ -0,0 +1,13 @@ +namespace GenLauncherGO.Tests.Testing; + +public sealed class SymbolicLinkFactAttribute : FactAttribute +{ + public SymbolicLinkFactAttribute() + { + 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..5a26c94d --- /dev/null +++ b/GenLauncherGO.Tests/Testing/SymbolicLinkTestSupport.cs @@ -0,0 +1,125 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Tests.Testing; + +internal static class SymbolicLinkTestSupport +{ + private const string RequiredEnvironmentVariable = "GENLAUNCHERGO_REQUIRE_SYMBOLIC_LINK_TESTS"; + private static readonly Lazy _symbolicLinkSupport = new(ProbeSymbolicLinkSupport); + + internal const string UnsupportedReason = + "These safety tests require Windows file and directory symbolic-link support. " + + "Enable Windows Developer Mode or run the tests with symbolic-link privileges."; + + internal static bool IsRequired => + Boolean.TryParse( + Environment.GetEnvironmentVariable(RequiredEnvironmentVariable), + out bool isRequired) && + isRequired; + + internal static bool IsSupported => _symbolicLinkSupport.Value; + + public static void CreateDirectoryLink(string linkPath, string targetPath) + { + CreateLink( + () => Directory.CreateSymbolicLink(linkPath, targetPath), + "directory"); + } + + 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 directoryTarget = Path.Combine(testRoot, "DirectoryTarget"); + string directoryLink = Path.Combine(testRoot, "DirectoryLink"); + string fileTarget = Path.Combine(testRoot, "FileTarget.txt"); + string fileLink = Path.Combine(testRoot, "FileLink.txt"); + + try + { + Directory.CreateDirectory(directoryTarget); + File.WriteAllText(fileTarget, "target"); + Directory.CreateSymbolicLink(directoryLink, directoryTarget); + File.CreateSymbolicLink(fileLink, fileTarget); + + return File.GetAttributes(directoryLink).HasFlag(FileAttributes.ReparsePoint) && + File.GetAttributes(fileLink).HasFlag(FileAttributes.ReparsePoint); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + finally + { + TryDeleteFileLink(fileLink); + TryDeleteDirectoryLink(directoryLink); + TryDeleteProbeRoot(testRoot); + } + } + + private static void TryDeleteFileLink(string fileLink) + { + try + { + File.Delete(fileLink); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + } + } + + private static void TryDeleteDirectoryLink(string directoryLink) + { + try + { + if (Directory.Exists(directoryLink)) + { + Directory.Delete(directoryLink); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + } + } + + private static void TryDeleteProbeRoot(string testRoot) + { + try + { + if (Directory.Exists(testRoot)) + { + Directory.Delete(testRoot, recursive: 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..b7032401 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestDirectory.cs @@ -0,0 +1,76 @@ +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 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; + } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherLaunchCoordinator.cs b/GenLauncherGO.Tests/Testing/TestLauncherLaunchCoordinator.cs new file mode 100644 index 00000000..6daedf39 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherLaunchCoordinator.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +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) + { + LauncherPackageActivityService resolvedPackageActivityService = packageActivityService ?? new(); + ILauncherPreferencesService resolvedPreferencesService = + preferencesService ?? Substitute.For(); + if (preferencesService == null) + { + resolvedPreferencesService.Current.Returns(new LauncherPreferences()); + } + + ILauncherStringLocalizer resolvedStringLocalizer = stringLocalizer ?? + new TestStringLocalizer(new Dictionary + { + ["FilesCorrupted"] = "Files corrupted", + ["GameRunning"] = "Game running", + ["LaunchAborted"] = "Launch aborted", + ["LaunchVerificationRunning"] = "Verification running", + ["Reinstall"] = "Reinstall", + ["WorldBuilderRunning"] = "World Builder running", + }); + ILauncherDialogService resolvedDialogService = + dialogService ?? Substitute.For(); + ILaunchPreparationService resolvedPreparationService = + preparationService ?? Substitute.For(); + if (preparationService == null) + { + resolvedPreparationService.Prepare( + Arg.Any(), + Arg.Any()) + .Returns(true); + resolvedPreparationService.Cleanup( + Arg.Any(), + Arg.Any()) + .Returns(true); + } + + IGameProcessLauncher resolvedProcessLauncher = + processLauncher ?? Substitute.For(); + if (processLauncher == null) + { + IGameProcessLaunchOperation completedProcessOperation = CreateCompletedProcessOperation(); + resolvedProcessLauncher.StartAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(completedProcessOperation)); + } + + ILaunchContentIntegrityResolutionService resolutionService = + integrityResolutionService ?? Substitute.For(); + if (integrityResolutionService == null) + { + resolutionService.VerifyAsync( + Arg.Any(), + Arg.Any()) + .Returns(new LaunchContentIntegrityVerificationResult( + new ContentIntegrityReport(Array.Empty()), + Array.Empty())); + } + LauncherPaths paths = TestLauncherPaths.Create(); + LauncherRuntimePathContext runtimePaths = TestLauncherPaths.CreateRuntimePathContext(paths); + + return new LauncherLaunchCoordinator( + resolvedPreferencesService, + resolvedPreparationService, + resolvedProcessLauncher, + new LaunchContentIntegrityCoordinator( + resolutionService, + catalog ?? new FakeLauncherContentCatalog(), + runtimePaths, + resolvedPackageActivityService, + resolvedStringLocalizer, + resolvedDialogService, + NullLogger.Instance), + resolvedPackageActivityService, + runtimePaths, + resolvedStringLocalizer, + resolvedDialogService, + NullLogger.Instance); + } + + private static IGameProcessLaunchOperation CreateCompletedProcessOperation() + { + IGameProcessLaunchOperation operation = Substitute.For(); + operation.CurrentExecutableName.Returns("generals.exe"); + operation.Completion.Returns(Task.FromResult(true)); + return operation; + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherPaths.cs b/GenLauncherGO.Tests/Testing/TestLauncherPaths.cs new file mode 100644 index 00000000..19b26fe3 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherPaths.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Testing; + +internal static class TestLauncherPaths +{ + private const string DefaultGameDirectory = @"C:\Games\ZeroHour"; + + 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) + { + ArgumentNullException.ThrowIfNull(directory); + + string gameDirectory = directory.CreateDirectory("Game"); + string executableDirectory = directory.CreateDirectory("Launcher"); + LauncherPaths paths = new LauncherStoragePaths(executableDirectory) + .CreateGamePaths(SupportedGame.ZeroHour, gameDirectory); + Directory.CreateDirectory(paths.ImagesDirectory); + Directory.CreateDirectory(paths.ModsDirectory); + Directory.CreateDirectory(paths.TempDirectory); + Directory.CreateDirectory(paths.DeploymentDirectory); + return paths; + } + + 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); + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherRuntimeContext.cs b/GenLauncherGO.Tests/Testing/TestLauncherRuntimeContext.cs new file mode 100644 index 00000000..bb9d97d9 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherRuntimeContext.cs @@ -0,0 +1,24 @@ +using GenLauncherGO.Core.Startup; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Shared.Themes; + +namespace GenLauncherGO.Tests.Testing; + +internal static class TestLauncherRuntimeContext +{ + public static LauncherRuntimeContext Create( + SupportedGame currentlyManagedGame = SupportedGame.ZeroHour, + ColorsInfo? colors = null) + { + var storagePaths = new LauncherStoragePaths(@"C:\Launcher"); + LauncherPaths gamePaths = storagePaths.CreateGamePaths( + currentlyManagedGame, + @"C:\Games\Game"); + return new LauncherRuntimeContext( + new LauncherRuntimePathContext(storagePaths, gamePaths), + "1.0.0-test") + { + Colors = colors ?? TestLauncherTheme.Create(), + }; + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherTheme.cs b/GenLauncherGO.Tests/Testing/TestLauncherTheme.cs new file mode 100644 index 00000000..a3c81088 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherTheme.cs @@ -0,0 +1,31 @@ +using Avalonia.Media; +using GenLauncherGO.UI.Shared.Themes; + +namespace GenLauncherGO.Tests.Testing; + +internal static class TestLauncherTheme +{ + public static ColorsInfo Create(IImageBrush? backgroundImage = null) + { + return new ColorsInfo( + border: "#00E3FF", + inactiveBorder: "DarkGray", + inactiveBorder2: "#7A7DB0", + activeColor: "#BAFF0C", + darkFill: "#232977", + darkBackground: "#090502", + lightBackground: "#B3000000", + text: "White", + text2: "Black", + selectionStartColor: "#F21D2057", + selectionMiddleColor: "#E61D2057", + buttonSelectionColor: "#2534FF", + buttonPointerOverColor: "#141D8C", + accentFillColor: "#173352", + subtleAccentFillColor: "#070B0F", + accentBorderColor: "#49A1FF", + subtleAccentBorderColor: "#36495E", + accentTextColor: "#C7E2FF", + backgroundImage: backgroundImage); + } +} diff --git a/GenLauncherGO.Tests/Testing/TestStringLocalizer.cs b/GenLauncherGO.Tests/Testing/TestStringLocalizer.cs new file mode 100644 index 00000000..0f4ea99f --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestStringLocalizer.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.UI.Shared.Localization; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Resolves test localization keys from an optional in-memory dictionary. +/// +internal sealed class TestStringLocalizer : ILauncherStringLocalizer +{ + /// + /// The configured localized values. + /// + private readonly IReadOnlyDictionary _values; + + /// + /// Creates a fallback value for missing keys. + /// + private readonly Func _fallback; + + /// + /// Initializes a new instance of the class. + /// + public TestStringLocalizer() + : this(new Dictionary + { + ["LatestVersion"] = "Latest version: ", + }) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The explicit localized values. + public TestStringLocalizer(IReadOnlyDictionary values) + : this(values, key => key) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The explicit localized values. + /// The value factory used when a key is missing. + public TestStringLocalizer( + IReadOnlyDictionary values, + Func fallback) + { + _values = values ?? throw new ArgumentNullException(nameof(values)); + _fallback = fallback ?? throw new ArgumentNullException(nameof(fallback)); + } + + /// + public string this[string key] => _values.TryGetValue(key, out string? value) ? value : _fallback(key); +} diff --git a/GenLauncherGO.Tests/UI/Features/Dialogs/Models/ManualModificationDialogRequestTests.cs b/GenLauncherGO.Tests/UI/Features/Dialogs/Models/ManualModificationDialogRequestTests.cs new file mode 100644 index 00000000..cec04cdf --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Dialogs/Models/ManualModificationDialogRequestTests.cs @@ -0,0 +1,18 @@ +using GenLauncherGO.UI.Features.Dialogs.Models; + +namespace GenLauncherGO.Tests.UI.Features.Dialogs.Models; + +public sealed class ManualModificationDialogRequestTests +{ + [Fact] + public void ConstructorCopiesSelectedFilesAndParentContentName() + { + string[] files = { @"C:\Packages\mod.zip" }; + + ManualModificationDialogRequest request = new(files, "ShockWave"); + files[0] = @"C:\Packages\changed.zip"; + + request.Files.Should().Equal(@"C:\Packages\mod.zip"); + request.ParentContentName.Should().Be("ShockWave"); + } +} 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..dc3c6bd7 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Dialogs/Services/AvaloniaLauncherDialogServiceTests.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.ExceptionServices; +using Avalonia.Controls; +using Avalonia.Threading; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Dialogs.Services; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Integrity.ViewModels; +using GenLauncherGO.UI.Features.Mods.ViewModels; +using GenLauncherGO.UI.Features.Mods.Views; +using GenLauncherGO.UI.Shared.Dialogs; + +namespace GenLauncherGO.Tests.UI.Features.Dialogs.Services; + +public sealed class AvaloniaLauncherDialogServiceTests +{ + [Fact] + public void DialogWithoutOwnerClosesWithInfoWindowResultAsync() + { + StaTestRunner.Run(async () => + { + InfoWindow dialog = new( + new LauncherInfoDialogRequest("Retry startup", "The startup operation failed."), + InfoDialogKind.WarningConfirmation, + TestLauncherTheme.Create(), + continueText: "Retry", + cancelText: "Cancel"); + Dispatcher.UIThread.Post(() => dialog.ViewModel.ContinueCommand.Execute(null)); + + bool confirmed = await AvaloniaDialog.ShowAsync( + dialog, + owner: null, + () => dialog.Accepted); + + confirmed.Should().BeTrue(); + dialog.IsVisible.Should().BeFalse(); + }); + } + + [Fact] + public void DialogWithoutOwnerClosesWithIntegrityReviewResultAsync() + { + StaTestRunner.Run(async () => + { + IntegrityReviewViewModel viewModel = new( + new ContentIntegrityReport(Array.Empty()), + new TestStringLocalizer()); + IntegrityReviewDialog dialog = new(viewModel); + Dispatcher.UIThread.Post(() => viewModel.ConfirmResolutionCommand.Execute(null)); + + bool confirmed = await AvaloniaDialog.ShowAsync( + dialog, + owner: null, + () => dialog.ResolutionConfirmed); + + confirmed.Should().BeTrue(); + dialog.IsVisible.Should().BeFalse(); + }); + } + + [Fact] + public void ShowWarningConfirmationAsync_PreservesCustomTextAndDetailFontSize() + { + StaTestRunner.Run(async () => + { + Window owner = new(); + owner.Show(); + Exception? callbackFailure = null; + double observedFontSize = 0; + string? observedContinueText = null; + string? observedCancelText = null; + bool observedWarningIcon = false; + + try + { + AvaloniaLauncherDialogService service = new( + TestLauncherRuntimeContext.Create(), + new TestStringLocalizer(new Dictionary + { + ["Continue"] = "Continue", + ["Cancel"] = "Cancel", + }), + new FakeLauncherContentCatalog(), + Substitute.For()); + LauncherInfoDialogRequest request = new( + "Unsafe operation", + "This operation changes managed files.", + detailFontSize: 12.5, + cancelText: "Go back"); + + Dispatcher.UIThread.Post(() => CompleteDialog(attempt: 0)); + + bool confirmed = await service.ShowWarningConfirmationAsync( + request, + continueText: "Proceed anyway", + owner); + + if (callbackFailure != null) + { + ExceptionDispatchInfo.Capture(callbackFailure).Throw(); + } + + confirmed.Should().BeTrue(); + observedFontSize.Should().Be(12.5); + observedContinueText.Should().Be("Proceed anyway"); + observedCancelText.Should().Be("Go back"); + observedWarningIcon.Should().BeTrue(); + } + finally + { + owner.Close(); + } + + void CompleteDialog(int attempt) + { + InfoWindow? dialog = owner.OwnedWindows.OfType().SingleOrDefault(); + if (dialog == null && attempt < 10) + { + Dispatcher.UIThread.Post(() => CompleteDialog(attempt + 1)); + return; + } + + try + { + dialog.Should().NotBeNull(); + 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..972bd45d --- /dev/null +++ b/GenLauncherGO.UI/Features/Launcher/Views/MainWindow.axaml.cs @@ -0,0 +1,577 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +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.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 MainWindowViewModel _viewModel = null!; + private readonly LauncherDragDropController _dragDropController = null!; + private readonly LauncherWindowListController _contentController = null!; + private readonly LauncherWindowWorkflowCoordinator _workflowCoordinator = null!; + private readonly IUiExceptionBoundary _exceptionBoundary = null!; + private readonly WindowsTaskbarProgress _taskbarProgress = new(); + private readonly HashSet> _activeWindowOperations = new(); + private CancellationTokenSource _windowLifetime = new(); + private bool _closePreparationInProgress; + private bool _closeApproved; + + public MainWindow() + { + InitializeComponent(); + } + + public MainWindow( + MainWindowViewModel viewModel, + LauncherDragDropController dragDropController, + LauncherRuntimeContext runtimeContext, + LauncherWindowWorkflowCoordinator workflowCoordinator, + 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)); + _exceptionBoundary = exceptionBoundary ?? throw new ArgumentNullException(nameof(exceptionBoundary)); + + DataContext = _viewModel; + _contentController = new LauncherWindowListController( + this, + _viewModel, + runtimeContext, + ModsList, + PatchesList, + AddonsList); + + Closing += MainWindow_ClosingAsync; + Opened += MainWindow_Opened; + Activated += MainWindow_Activated; + ModsList.AddHandler( + InputElement.PointerPressedEvent, + ModsList_PointerPressed, + RoutingStrategies.Tunnel); + ModsList.AddHandler( + InputElement.PointerMovedEvent, + ModsList_PointerMoved, + RoutingStrategies.Tunnel); + ModsList.AddHandler( + InputElement.PointerReleasedEvent, + ModsList_PointerReleased, + RoutingStrategies.Tunnel); + PatchesList.AddHandler( + InputElement.PointerPressedEvent, + PatchesList_PointerPressed, + RoutingStrategies.Tunnel); + PatchesList.AddHandler( + InputElement.PointerReleasedEvent, + PatchesList_PointerReleased, + RoutingStrategies.Tunnel); + _viewModel.PropertyChanged += ViewModel_PropertyChanged; + + _viewModel.Initialize(); + _contentController.Initialize(); + UpdateContentViewVisibility(); + } + + private void MainWindow_Opened(object? sender, EventArgs eventArgs) + { + _taskbarProgress.Attach(this); + UpdateTaskbarProgress(); + } + + private void MainWindow_Activated(object? sender, EventArgs eventArgs) + { + _viewModel.RefreshGameClientOptions(); + _viewModel.RefreshWorldBuilderOptions(); + } + + private void ModsList_PointerPressed(object? sender, PointerPressedEventArgs eventArgs) + { + _dragDropController.CapturePointerGesture( + ModsList, + this, + eventArgs, + canReorder: true); + } + + private void ModsList_PointerMoved(object? sender, PointerEventArgs eventArgs) + { + _dragDropController.HandlePointerMove(ModsList, eventArgs); + } + + private void ModsList_PointerReleased(object? sender, PointerReleasedEventArgs eventArgs) + { + CompleteContentPointerGesture(ModsList, eventArgs); + } + + private void PatchesList_PointerPressed(object? sender, PointerPressedEventArgs eventArgs) + { + _dragDropController.CapturePointerGesture( + PatchesList, + this, + eventArgs, + canReorder: false); + } + + private void PatchesList_PointerReleased(object? sender, PointerReleasedEventArgs eventArgs) + { + CompleteContentPointerGesture(PatchesList, eventArgs); + } + + private void CompleteContentPointerGesture( + ListBox contentList, + PointerReleasedEventArgs eventArgs) + { + if (_dragDropController.TryCompletePointerGesture( + contentList, + eventArgs, + out ModificationViewModel? selectionToggleCandidate, + out int sourceIndex, + out int targetIndex)) + { + _viewModel.MoveModInList(sourceIndex, targetIndex); + eventArgs.Handled = true; + } + + if (selectionToggleCandidate != null && + _viewModel.TryClearContentSelection(selectionToggleCandidate)) + { + eventArgs.Handled = true; + } + } + + private void TileContextMenu_Opened(object? sender, RoutedEventArgs eventArgs) + { + _dragDropController?.CancelPointerGesture(); + } + + private async void ModsList_SelectionChangedAsync(object? sender, SelectionChangedEventArgs eventArgs) + { + await ExecuteWindowOperationAsync( + "changing the selected modification", + () => _contentController.HandleModsListSelectionChangedAsync(eventArgs)); + } + + private async void PatchesList_SelectionChangedAsync(object? sender, SelectionChangedEventArgs eventArgs) + { + await ExecuteSyncAsync( + "changing the selected patch", + () => _contentController.HandlePatchesListSelectionChanged(eventArgs)); + } + + private async void AddonsList_SelectionChangedAsync(object? sender, SelectionChangedEventArgs eventArgs) + { + await ExecuteSyncAsync( + "changing a selected add-on", + () => _contentController.HandleAddonsListSelectionChanged(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); + } + + _viewModel.SaveLauncherData(); + }, + this); + + if (outcome != UiOperationOutcome.Succeeded) + { + _windowLifetime.Dispose(); + _windowLifetime = new CancellationTokenSource(); + _closePreparationInProgress = false; + IsEnabled = true; + return; + } + + _dragDropController.CancelPointerGesture(); + _viewModel.PropertyChanged -= ViewModel_PropertyChanged; + _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, + _viewModel, + _contentController, + this, + _windowLifetime.Token)); + } + + private async void LaunchWorldBuilder_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ExecuteWindowOperationAsync( + "launching World Builder", + () => _workflowCoordinator.LaunchAsync( + GameLaunchTargetKind.WorldBuilder, + _viewModel, + _contentController, + this, + _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( + _viewModel, + _contentController, + this, + _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", + () => _workflowCoordinator.AddRepositoryModificationAsync( + _viewModel, + _contentController, + this, + _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", + () => _workflowCoordinator.ImportManualContentAsync( + _viewModel, + _contentController, + this, + kind, + _windowLifetime.Token)); + } + + private async void UpdateModification_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + if (GetModification(sender) is not { } modification) + { + return; + } + + await ExecuteWindowOperationAsync( + "updating launcher content", + () => _workflowCoordinator.UpdateModificationAsync( + _viewModel, + _contentController, + this, + modification)); + } + + private async void ChangeVersionImage_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + if (GetModification(sender) is not { } modification) + { + return; + } + + await ExecuteWindowOperationAsync( + "changing a modification image", + () => _workflowCoordinator.ChangeVersionImageAsync( + _viewModel, + this, + modification, + _windowLifetime.Token)); + } + + private async void OpenChangeLog_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ApplyModificationActionAsync(sender, "opening a change log", _workflowCoordinator.OpenChangeLog); + } + + private async void OpenNetworkInfo_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ApplyModificationActionAsync( + sender, + "opening network information", + _workflowCoordinator.OpenNetworkInfo); + } + + private async void OpenSupport_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ApplyModificationActionAsync(sender, "opening a support link", _workflowCoordinator.OpenSupport); + } + + private async void OpenModDb_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ApplyModificationActionAsync(sender, "opening a Mod DB link", _workflowCoordinator.OpenModDb); + } + + private async void OpenDiscord_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ApplyModificationActionAsync(sender, "opening a Discord link", _workflowCoordinator.OpenDiscord); + } + + private async void DeleteVersion_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + if (sender is Control { DataContext: ModificationVersionSelection versionSelection }) + { + await ExecuteWindowOperationAsync( + "deleting a content version", + () => _workflowCoordinator.DeleteVersionAsync( + _viewModel, + _contentController, + this, + versionSelection)); + } + } + + private async void DeleteModification_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + if (GetModification(sender) is not { } modification) + { + return; + } + + await ExecuteWindowOperationAsync( + "deleting launcher content", + () => _workflowCoordinator.DeleteModificationAsync( + _viewModel, + _contentController, + this, + 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 void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs eventArgs) + { + if (eventArgs.PropertyName is nameof(MainWindowViewModel.TaskbarProgressState) or + nameof(MainWindowViewModel.TaskbarProgressValue)) + { + UpdateTaskbarProgress(); + 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 void UpdateTaskbarProgress() + { + _taskbarProgress.Update( + _viewModel.TaskbarProgressState, + _viewModel.TaskbarProgressValue); + } + + 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..3a0b1b8b --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ModificationImageSourceFactory.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Concurrent; +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(System.Globalization.CultureInfo.InvariantCulture), + ":", + fileInfo.LastWriteTimeUtc.Ticks.ToString(System.Globalization.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..26b3427c --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ModificationVersionSelection.cs @@ -0,0 +1,26 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.UI.Features.Mods; + +internal sealed class ModificationVersionSelection +{ + public ModificationVersionSelection( + LauncherContentVersion selectedModification, + string version, + ModificationViewModel modificationViewModel) + { + SelectedVersion = selectedModification; + VersionName = version; + ModificationViewModel = modificationViewModel; + } + + public ModificationVersionSelection() + { + } + + public string VersionName { get; set; } = string.Empty; + + public LauncherContentVersion SelectedVersion { get; set; } = null!; + + public ModificationViewModel ModificationViewModel { get; set; } = null!; +} diff --git a/GenLauncherGO.UI/Features/Mods/ModificationViewModel.cs b/GenLauncherGO.UI/Features/Mods/ModificationViewModel.cs new file mode 100644 index 00000000..dc2ba236 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ModificationViewModel.cs @@ -0,0 +1,832 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using Avalonia; +using Avalonia.Media; +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 GenLauncherGO.UI.Shared.Themes; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.UI.Features.Mods; + +/// +/// Represents bindable UI state for a launcher modification tile. +/// +internal sealed class ModificationViewModel : ObservableObject, ILaunchContentIntegrityProgressTarget +{ + // Tile construction can precede theme resource initialization during early startup and tests. + private static readonly IBrush _fallbackActiveBrush = new SolidColorBrush(Color.FromRgb(186, 255, 12)); + + private static readonly IBrush _fallbackBorderBrush = new SolidColorBrush(Color.FromRgb(0, 227, 255)); + + private static readonly IBrush _fallbackDefaultTextBrush = Brushes.White; + + private static readonly IBrush _fallbackDownloadTextBrush = Brushes.Black; + + private static readonly IBrush _fallbackInactiveBrush = Brushes.DarkGray; + + private static readonly IBrush _fallbackProgressBackgroundBrush = Brushes.Black; + + private static readonly IBrush _fallbackActiveProgressBrush = new SolidColorBrush(Color.FromRgb(37, 52, 255)); + + private readonly LauncherContent _containerModification; + + private readonly LauncherRuntimeContext _launcherContext; + + private readonly LauncherPackageActivityService _packageActivityService; + + private readonly ModificationTileImageProvider _imageProvider; + + private readonly ILauncherStringLocalizer _stringLocalizer; + + private LauncherContentVersion? _selectedVersion; + + private bool _readyToRun = true; + + private IImage? _imageSource; + + private IImage? _selectedImageSource; + + private bool _isSelected; + + private bool _isVersionSelectorVisible; + + private bool _isVersionActionVisible; + + private bool _isDragAndDropVisible; + + private bool _isUpdateButtonVisible = true; + + private bool _isSupportButtonVisible = true; + + private bool _isNetworkInfoVisible = true; + + private bool _isChangeLogVisible = true; + + private Thickness _imageBorderThickness = new(0); + + private IBrush _progressBackground = _fallbackProgressBackgroundBrush; + + private IBrush _progressForeground = _fallbackActiveProgressBrush; + + private IBrush _progressBorderBrush = _fallbackInactiveBrush; + + private IBrush _progressTextForeground = _fallbackDefaultTextBrush; + + private double _progressValue; + + private string _progressMessage = string.Empty; + + private string _updateButtonContent; + + private string _supportButtonContent; + + private string _changeLogButtonContent; + + private string _networkInfoButtonContent; + + private string _versionActionContent; + + private bool _updateButtonEnabled = true; + + private bool _updateButtonBlinking; + + private bool _supportButtonBlinking; + + private bool _integrityProgressActive; + + private bool _forwardedChildPackageActivityActive; + + private bool _isVersionSelectorEnabled = true; + + private ModificationVersionSelection? _selectedVersionOption; + + 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)); + RefreshSelectedVersion(); + + _updateButtonContent = _stringLocalizer["Update"]; + _supportButtonContent = _stringLocalizer["Donate"]; + _changeLogButtonContent = _stringLocalizer["ChangelogOnly"]; + _networkInfoButtonContent = _stringLocalizer["PlayOnline"]; + _versionActionContent = _stringLocalizer["RemoveFromList"]; + + InitializeVisualState(); + } + + /// + /// Occurs when package download or repair activity state changes for this tile. + /// + public event EventHandler? PackageActivityChanged; + + public LauncherContent ContainerModification => _containerModification; + + public LauncherContentVersion LatestVersion => ContainerModification.LatestVersion; + + public LauncherContentVersion? SelectedVersion => _selectedVersion; + + public string NameInfo => ContainerModification.Name; + + public string LatestVersionInfo => + ContainerModification.ModificationType == ModificationType.Advertising + ? LatestVersion.Version + : String.Concat(_stringLocalizer["LatestVersion"], LatestVersion.Version); + + public bool ReadyToRun => _readyToRun; + + 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 is + ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile); + + public LauncherContentVersion? ActiveIntegrityVersion => SelectedVersion ?? LatestVersion; + + public bool CanReportIntegrityProgress => ActiveIntegrityVersion != null; + + /// + /// 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; } = new(); + + public ModificationVersionSelection? SelectedVersionOption + { + get => _selectedVersionOption; + set => SetProperty(ref _selectedVersionOption, value); + } + + public bool IsSelected + { + get => _isSelected; + set + { + if (SetProperty(ref _isSelected, 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 => _imageSource; + private set + { + if (SetProperty(ref _imageSource, value)) + { + OnPropertyChanged(nameof(HasImage)); + } + } + } + + public IImage? SelectedImageSource + { + get => _selectedImageSource; + private set + { + if (SetProperty(ref _selectedImageSource, value)) + { + OnPropertyChanged(nameof(HasImage)); + } + } + } + + public bool HasImage => ImageSource != null || SelectedImageSource != null; + + public bool IsVersionSelectorVisible + { + get => _isVersionSelectorVisible; + private set => SetProperty(ref _isVersionSelectorVisible, value); + } + + public bool IsVersionActionVisible + { + get => _isVersionActionVisible; + private set => SetProperty(ref _isVersionActionVisible, value); + } + + public bool IsDragAndDropVisible + { + get => _isDragAndDropVisible; + private set => SetProperty(ref _isDragAndDropVisible, value); + } + + public bool IsUpdateButtonVisible + { + get => _isUpdateButtonVisible; + private set => SetProperty(ref _isUpdateButtonVisible, value); + } + + public bool IsSupportButtonVisible + { + get => _isSupportButtonVisible; + private set => SetProperty(ref _isSupportButtonVisible, value); + } + + public bool IsNetworkInfoVisible + { + get => _isNetworkInfoVisible; + private set => SetProperty(ref _isNetworkInfoVisible, value); + } + + public bool IsChangeLogVisible + { + get => _isChangeLogVisible; + private set => SetProperty(ref _isChangeLogVisible, value); + } + + public Thickness ImageBorderThickness + { + get => _imageBorderThickness; + private set => SetProperty(ref _imageBorderThickness, value); + } + + public IBrush ProgressBackground + { + get => _progressBackground; + private set => SetProperty(ref _progressBackground, value); + } + + public IBrush ProgressForeground + { + get => _progressForeground; + private set => SetProperty(ref _progressForeground, value); + } + + public IBrush ProgressBorderBrush + { + get => _progressBorderBrush; + private set => SetProperty(ref _progressBorderBrush, value); + } + + public IBrush ProgressTextForeground + { + get => _progressTextForeground; + private set => SetProperty(ref _progressTextForeground, value); + } + + public double ProgressValue + { + get => _progressValue; + private set => SetProperty(ref _progressValue, value); + } + + public string ProgressMessage + { + get => _progressMessage; + private set => SetProperty(ref _progressMessage, value); + } + + public string UpdateButtonContent + { + get => _updateButtonContent; + private set => SetProperty(ref _updateButtonContent, value); + } + + public string SupportButtonContent + { + get => _supportButtonContent; + private set => SetProperty(ref _supportButtonContent, value); + } + + public string ChangeLogButtonContent + { + get => _changeLogButtonContent; + private set => SetProperty(ref _changeLogButtonContent, value); + } + + public string NetworkInfoButtonContent + { + get => _networkInfoButtonContent; + private set => SetProperty(ref _networkInfoButtonContent, value); + } + + public bool UpdateButtonEnabled + { + get => _updateButtonEnabled; + private set => SetProperty(ref _updateButtonEnabled, value); + } + + public bool UpdateButtonBlinking + { + get => _updateButtonBlinking; + private set => SetProperty(ref _updateButtonBlinking, value); + } + + public bool SupportButtonBlinking + { + get => _supportButtonBlinking; + private set => SetProperty(ref _supportButtonBlinking, value); + } + + public bool IsVersionSelectorEnabled + { + get => _isVersionSelectorEnabled; + private set => SetProperty(ref _isVersionSelectorEnabled, value); + } + + 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, + version.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); + + try + { + ResetDownloadVisuals(); + RefreshFromModel(); + if (ContainerModification.ModificationType != ModificationType.Advertising) + { + UpdateComboBox(); + SelectItemInComboBox(); + } + else + { + HideVersionSelector(); + } + + RefreshContentButtonAvailability(); + RefreshImages(); + ApplyTerminalDownloadResult(result); + } + finally + { + OnPackageActivityChanged(); + } + } + + private void ApplyTerminalDownloadResult(PackageDownloadResult result) + { + switch (result.Status) + { + case PackageDownloadStatus.Succeeded: + ApplyPackageActivityVisualState(isActive: false); + break; + case PackageDownloadStatus.Canceled: + SetStatusMessage(_stringLocalizer["Canceled"]); + ApplyPackageActivityVisualState(isActive: false); + 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."); + } + } + + private void ShowDownloadFailure(string message) + { + SetStatusMessage(String.Concat(_stringLocalizer["Error"], message)); + ApplyPackageActivityVisualState(isActive: false); + } + + /// + /// Prepares tile state for package download state. + /// + public void BeginPackageActivityPresentation() + { + UpdateButtonContent = _stringLocalizer["Pause"]; + UpdateButtonBlinking = false; + IsVersionSelectorEnabled = false; + _readyToRun = false; + OnStatePropertiesChanged(); + + RefreshContentButtonAvailability(); + ApplyPackageActivityVisualState(isActive: true); + OnPackageActivityChanged(); + } + + public string VersionActionContent + { + get => _versionActionContent; + private set => SetProperty(ref _versionActionContent, value); + } + + /// + /// 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(); + } + + public void BeginIntegrityProgress(string message) + { + _integrityProgressActive = true; + ApplyPackageActivityVisualState(isActive: true); + ReportPackageProgress(message, 0); + OnPackageActivityChanged(); + } + + public void ReportIntegrityProgress(string message, int percentage) + { + ReportPackageProgress(message, percentage); + } + + public void CompleteIntegrityProgress() + { + _integrityProgressActive = false; + RefreshFromModelAndPresentation(); + ApplyPackageActivityVisualState(isActive: false); + OnPackageActivityChanged(); + } + + /// + /// 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(isActive: true); + ReportPackageProgress(message, percentage); + OnPackageActivityChanged(); + } + + /// + /// Clears mirrored child-content package activity from this parent tile. + /// + public void CompleteForwardedChildPackageActivity() + { + if (!_forwardedChildPackageActivityActive) + { + return; + } + + _forwardedChildPackageActivityActive = false; + RefreshFromModelAndPresentation(); + ApplyPackageActivityVisualState(isActive: false); + OnPackageActivityChanged(); + } + + private IBrush ActiveBrush => ResolveBrush(_launcherContext.Colors.GenLauncherActiveColor, _fallbackActiveBrush); + + private IBrush BorderBrush => ResolveBrush(_launcherContext.Colors.GenLauncherBorderColor, _fallbackBorderBrush); + + private IBrush DefaultTextBrush => + ResolveBrush(_launcherContext.Colors.GenLauncherDefaultTextColor, _fallbackDefaultTextBrush); + + private IBrush DownloadTextBrush => + ResolveBrush(_launcherContext.Colors.GenLauncherDownloadTextColor, _fallbackDownloadTextBrush); + + private IBrush InactiveBrush => + ResolveBrush(_launcherContext.Colors.GenLauncherInactiveBorder, _fallbackInactiveBrush); + + private IBrush ProgressBackgroundBrush => + ResolveBrush(_launcherContext.Colors.GenLauncherDarkBackGround, _fallbackProgressBackgroundBrush); + + private IBrush ActiveProgressBrush => _launcherContext.Colors.GenLauncherButtonSelectionColor == default + ? _fallbackActiveProgressBrush + : new SolidColorBrush(_launcherContext.Colors.GenLauncherButtonSelectionColor); + + private void InitializeVisualState() + { + ResetDownloadVisuals(); + RefreshFromModelAndPresentation(); + } + + 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(); + if (_selectedVersion != null) + { + _selectedVersion.Installation.IsSelected = true; + } + } + + private LauncherContentVersion SelectLatestInstalledVersion() + { + if (_selectedVersion != null) + { + _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)); + OnPropertyChanged(nameof(CanReportIntegrityProgress)); + } + + private void OnPackageActivityChanged() + { + OnPropertyChanged(nameof(HasActivePackageActivity)); + PackageActivityChanged?.Invoke(this, EventArgs.Empty); + } + + private static IBrush ResolveBrush(IBrush? brush, IBrush fallback) + { + return brush ?? fallback; + } +} 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..ffe05e2c --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationItemViewModel.cs @@ -0,0 +1,58 @@ +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 _versionText = "\u2026"; + + 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 => _versionText; + private set + { + SetProperty(ref _versionText, value); + } + } + + 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..a20e483b --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationViewModel.cs @@ -0,0 +1,276 @@ +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 IRemotePackageSizeResolver _packageSizeResolver; + + private readonly ILogger _logger; + + private readonly string _packageSizeUnavailableText; + + private readonly CancellationTokenSource _metadataCancellation = new(); + + private string _searchText = string.Empty; + + private AddModificationItemViewModel? _selectedModification; + + private bool _metadataLoadingStarted; + + 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); + } + + /// + /// Occurs when the view model requests that the owning dialog close. + /// + public event EventHandler? CloseRequested; + + public ObservableCollection VisibleModifications { get; } + + public string SearchText + { + get => _searchText; + set + { + string newValue = value ?? string.Empty; + if (String.Equals(_searchText, newValue, StringComparison.Ordinal)) + { + return; + } + + _searchText = newValue; + OnPropertyChanged(); + ApplyFilter(); + } + } + + 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; } + + /// + /// 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(); + } + } + + public void Dispose() + { + _metadataCancellation.Dispose(); + } + + 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 + { + try + { + long? totalBytes = await _packageSizeResolver.GetTotalBytesAsync(version, cancellationToken); + item.SetPackageSize(totalBytes.HasValue + ? ByteSizeFormatter.Format(totalBytes.Value) + : _packageSizeUnavailableText); + } + 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); + item.SetPackageSize(_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; + } + + DialogResult = true; + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + private void Cancel() + { + DialogResult = false; + 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..0f6f8e7b --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogKind.cs @@ -0,0 +1,10 @@ +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +internal enum InfoDialogKind +{ + Info, + + 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..3a6cc47b --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogViewModel.cs @@ -0,0 +1,100 @@ +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) + { + 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; + OkCommand = new RelayCommand(Accept); + CancelCommand = new RelayCommand(Cancel); + ContinueCommand = OkCommand; + CloseCommand = new RelayCommand(Close); + + IsWarningConfirmation = kind == InfoDialogKind.WarningConfirmation; + IsOkVisible = kind != InfoDialogKind.WarningConfirmation; + IsContinueVisible = kind == InfoDialogKind.WarningConfirmation; + IsCancelVisible = kind == InfoDialogKind.WarningConfirmation; + IsInfoIconVisible = kind == InfoDialogKind.Info; + IsWarningIconVisible = kind == InfoDialogKind.WarningConfirmation; + IsErrorIconVisible = kind == InfoDialogKind.Error; + } + + /// + /// Occurs when the view model requests that the owning dialog close. + /// + public event EventHandler? CloseRequested; + + public string MainMessage { get; } + + public string DetailMessage { get; } + + public double DetailFontSize { get; } + + public string? ContinueText { get; } + + public string CancelText { get; } + + public bool IsOkVisible { get; } + + public bool IsContinueVisible { 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 CloseCommand { get; } + + /// + /// Gets the result requested by the dialog command. + /// + public bool? DialogResult { get; private set; } + + private bool IsWarningConfirmation { get; } + + private void Accept() + { + DialogResult = true; + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + private void Cancel() + { + DialogResult = false; + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + private void Close() + { + if (IsWarningConfirmation) + { + 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..88e2db67 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/ManualAddModificationViewModel.cs @@ -0,0 +1,248 @@ +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 IReadOnlyList _files; + + private readonly string? _parentContentName; + + private readonly ILauncherStringLocalizer _stringLocalizer; + + private readonly ILauncherDialogService _dialogService; + + private string _modificationName; + + private string _version = string.Empty; + + public ManualAddModificationViewModel( + IReadOnlyList files, + string? parentContentName, + ILauncherStringLocalizer stringLocalizer, + ILauncherDialogService dialogService) + { + ArgumentNullException.ThrowIfNull(files); + + _files = files.ToList(); + _parentContentName = parentContentName; + _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); + } + + /// + /// Occurs when the view model requests that the owning dialog close. + /// + public event EventHandler? CloseRequested; + + 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 => _version; + set + { + string newValue = value ?? string.Empty; + if (String.Equals(_version, newValue, StringComparison.Ordinal)) + { + return; + } + + _version = newValue; + NotifyInputChanged(nameof(Version), nameof(VersionValidationMessage)); + } + } + + 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; } + + private async Task AcceptAsync() + { + string? validationKey = GetFirstValidationKey(); + if (validationKey != null) + { + await ShowValidationErrorAsync(validationKey); + return; + } + + ImportResult = new ManualModificationDialogResult( + _files, + _parentContentName, + ModificationName, + Version); + DialogResult = true; + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + private void Cancel() + { + DialogResult = false; + 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 >= '0' && character <= '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..df166cf4 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/ModificationTileImageProvider.cs @@ -0,0 +1,205 @@ +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.Core.Startup; +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 IModificationImageFileService _modificationImageFileService; + + private readonly ILogger _logger; + + 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 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.Name, + latestVersion.Version, + grayscale: true, + useDefaultWhenMissing: 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.Name, + latestVersion.Version, + grayscale: false, + useDefaultWhenMissing: true); + } + + private IImage? LoadAdvertisingImage(LauncherContent modification) + { + string folderName = modification.Name.Trim(Path.GetInvalidFileNameChars()); + + int filesCount = _modificationImageFileService.CountImageFiles(folderName); + 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(folderName, imageBaseName); + + return LoadImage( + imageFileName, + folderName, + imageBaseName, + grayscale: false, + useDefaultWhenMissing: false); + } + + private string? GetModificationImageFileName( + LauncherContent modification, + LauncherContentVersion latestVersion, + bool localMod) + { + string? imageFileName = _modificationImageFileService.FindExistingImageFilePath( + 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, + 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, + modificationName, + imageBaseName, + grayscale, + useDefaultWhenMissing); + } + } + + /// + /// Removes an invalid cached image and falls back to the default image when appropriate. + /// + private IImage? TryRemoveInvalidImage( + string? path, + string modificationName, + string imageBaseName, + bool grayscale, + bool useDefaultWhenMissing) + { + try + { + _modificationImageFileService.TryDeleteImage(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..4d03b17f --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/Views/AddModificationWindow.axaml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - -