diff --git a/AGENTS.md b/AGENTS.md index 738fdde..61a3ec8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,14 @@ or local-only API URLs. Windows updates use WinSparkle/AppCast and the signed Inno Setup artifact; they are separate from game-client synchronization. - The server manifest is the source of truth for client files. +- Sync removes files absent from the manifest under `Data` and `WoW/Data`, + plus legacy `WoW.exe` and DLLs directly in the installation root or `WoW`. + Also clean obsolete `Extentions` directories in either client root and remove + the directory itself when no manifest files require it. + Remove unused Data subdirectories only after their children, without recursive + deletion. Preserve unrelated files and user state outside these areas. +- Language archives under `Data/ruRU` or `WoW/Data/ruRU` use the regular manifest sync. + Pass `WOW_LOCALE=auto` to the game so its selected install determines the locale. - Downloads must write to `*.moonwell.part`, verify size and SHA-256, then replace the destination file only after successful verification. - Local scans should ignore volatile client directories and launcher metadata: diff --git a/README.md b/README.md index 8347343..e31ad74 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,15 @@ The launcher is responsible for: putting credentials in command-line arguments - starting the Rust MoonWell client on Windows and macOS +During synchronization, the manifest is authoritative for `Data/` and +`WoW/Data/`: files absent from it and unused subdirectories are removed. +Legacy `WoW.exe` and DLLs in the installation root or `WoW/` are also removed +when absent from the manifest. Required DLLs stay; player settings, addons, +logs, screenshots, and other files outside these cleanup areas are preserved. +The obsolete `Extentions/` directory in either client root is cleaned the same +way and removed when no manifest files require it. +Cleanup runs even when all manifest files are already up to date. + ## Stack - Flutter desktop @@ -66,6 +75,12 @@ folder. For builds that pin an endpoint, set `--dart-define=MOONWELL_AUTH_ADDRESS=login.example:3724` instead. This endpoint is the vMaNGOS auth server, separate from the realm's world port and the Web API. +Russian 1.12.1 language archives are regular manifest files under `Data/ruRU` +(or `WoW/Data/ruRU` for a nested install). They are downloaded and verified with +the same size and SHA-256 checks as the base client. The launcher passes +`WOW_LOCALE=auto` to the game: an installed pack selects Russian, and a base-only +install selects English, regardless of the launcher's inherited environment. + Example run command: ```powershell diff --git a/docs/launcher_web_api_spec.md b/docs/launcher_web_api_spec.md index 43b21d9..196c762 100644 --- a/docs/launcher_web_api_spec.md +++ b/docs/launcher_web_api_spec.md @@ -137,7 +137,8 @@ Path safety rules: ## Ignored Directories -The launcher excludes these top-level directories from verification: +Unmanaged files in these directories outside `Data/` and `WoW/Data/` are +preserved and excluded from verification: - `Cache` - `Errors` @@ -145,6 +146,8 @@ The launcher excludes these top-level directories from verification: - `Screenshots` - `WTF` - `benilla-config` +- `.moonwell_launcher` +- `Interface/AddOns` Files under these directories: @@ -152,15 +155,38 @@ Files under these directories: - do not participate in local `buildHash` - are not compared against server manifest +Inside `Data/` and `WoW/Data/`, the manifest is authoritative for every file, +including nested files whose names contain `wtf`, `cache`, or `addons`. +The exclusions above do not exempt files inside Data from cleanup. + ## Local Scan -For every non-ignored file in the installation directory, the launcher computes: +For every existing manifest file in the installation directory, the launcher computes: - normalized relative path - file size in bytes - file `sha256` -The result is stored as a `ClientInstallationSnapshot`. +The result is stored as a `ClientInstallationSnapshot`. Paths are always relative +to the selected installation root, including files in nested directories. + +The scan separately collects stale paths without hashing their contents: + +- every file absent from the manifest in `Data/` and `WoW/Data/` +- every file absent from the manifest in obsolete `Extentions/` and + `WoW/Extentions/` directories, including unused subdirectories and the + Extentions directory itself when no manifest files require it +- `WoW.exe` and `*.dll` absent from the manifest directly in the installation + root or `WoW/` (binary names are matched without case sensitivity) +- unused Data subdirectories, ordered after their children; the Data roots and + directories needed by manifest files are retained + +These paths do not contribute to `buildHash`, but prevent sync from completing +until cleanup and final verification succeed. Other unmanaged files are preserved. +On Windows, manifest membership checks for cleanup ignore path letter case. +Directory links are not traversed during cleanup. Links absent from the manifest +inside Data are removed as links, and deletion through a parent pointing outside +the installation is rejected. Directory deletion is never recursive. ## Hash Cache @@ -224,15 +250,15 @@ The current sync flow is: 2. Scan local installation. 3. Compute local `buildHash`. 4. Compare local files to manifest files by `path`, `size`, and `sha256`. -5. Identify stale local files that are not present in the manifest. +5. Identify stale paths in the cleanup areas described above. 6. If local `buildHash` matches server `buildHash` and there are no stale files and no mismatches, finish successfully. -7. Delete stale local files outside ignored directories. +7. Delete stale paths, including unused Data subdirectories after their children. 8. Download changed and missing files. 9. Verify every downloaded file by `sha256`. 10. Re-scan the installation. 11. Recompute local `buildHash`. -12. Fail if final snapshot still differs from manifest. +12. Fail if final snapshot still differs from manifest or stale paths remain. ## Pause and Resume Semantics @@ -263,6 +289,8 @@ When the user presses `Play`: - `WOW_HOST=` - `WOW_DATA=` - `BENILLA_HOME=/benilla-config` + - `WOW_LOCALE=auto` (select the installed `Data/ruRU` or `WoW/Data/ruRU` language pack, + otherwise English; override any locale inherited from the launcher's parent) 6. launcher retains a process handle; while the process is alive, it disables repeated game launches and client synchronization 7. when the process exits, the launcher returns to the ready-to-play state @@ -274,6 +302,10 @@ If the executable, data directory, or auth endpoint is missing or invalid, launch fails with an error. A consumed ticket cannot authenticate again; close the client and press Play for a fresh ticket after a session ends. +Language archives are ordinary client-manifest entries, including their nested paths, +sizes and SHA-256 hashes. Synchronization verifies and retains required `ruRU` files +and removes obsolete archives under the same manifest-authoritative Data policy. + ## Error Behavior The launcher surfaces errors for: @@ -295,8 +327,13 @@ The automated tests cover: - deterministic `buildHash` - path normalization used by `buildHash` -- exclusion of ignored directories during local scan +- preservation of user state and unrelated unmanaged files during cleanup +- removal of unmanaged Data files, unused subdirectories, WoW.exe, DLLs, and + obsolete Extentions directories +- preservation of manifest-listed DLLs and WoW.exe - cache cleanup behavior - safe path resolution - sync flow with fully up-to-date client - sync flow with stale files and changed files +- cleanup when all manifest files are already up to date +- rejection of stale paths remaining after final verification diff --git a/docs/production_deploy.md b/docs/production_deploy.md index 0c97ff2..f266fdf 100644 --- a/docs/production_deploy.md +++ b/docs/production_deploy.md @@ -169,6 +169,10 @@ for all endpoint, bucket, key, and signing-path overrides. The script stops on the first failed command. +External commands are judged by their exit codes. Windows PowerShell may display +normal OpenSSL stderr notices as error records; they do not fail a successful +command. Key matching and installer-signature verification still must pass. + - Failure before S3 upload leaves production unchanged. - Failure after S3 upload but before AppCast publication leaves clients on the preceding AppCast. Fix the issue and rerun the same release. diff --git a/lib/features/launcher/application/client_sync_use_case.dart b/lib/features/launcher/application/client_sync_use_case.dart index 52af861..2bdfdc8 100644 --- a/lib/features/launcher/application/client_sync_use_case.dart +++ b/lib/features/launcher/application/client_sync_use_case.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'package:injectable/injectable.dart'; -import 'package:moonwell_launcher/config.dart'; import 'package:moonwell_launcher/core/use_case.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart'; @@ -117,7 +116,7 @@ class ClientSyncUseCase await _throwIfCancelled(input.isCancelled); final filesToDownload = _resolveFilesToDownload(manifest, localSnapshot); - final staleFiles = _resolveStaleFiles(manifest, localSnapshot); + final staleFiles = localSnapshot.stalePaths; _emit( controller, @@ -275,24 +274,6 @@ class ClientSyncUseCase }).toList(); } - List _resolveStaleFiles( - ClientManifest manifest, - ClientInstallationSnapshot snapshot, - ) { - final remoteFiles = manifest.filesByPath; - - return snapshot.files - .where((localFile) { - if (remoteFiles.containsKey(localFile.path)) { - return false; - } - - return !_isIgnored(localFile.path); - }) - .map((file) => file.path) - .toList(); - } - Future _removeStaleFiles({ required StreamController controller, required String installationDir, @@ -471,13 +452,7 @@ class ClientSyncUseCase return isMismatched; }).toList(); - final extraFiles = snapshot.files.where((localFile) { - if (manifest.filesByPath.containsKey(localFile.path)) { - return false; - } - - return !_isIgnored(localFile.path); - }).toList(); + final extraFiles = snapshot.stalePaths; if (snapshot.buildHash != remoteBuildHash || mismatchedFiles.isNotEmpty || @@ -493,14 +468,16 @@ class ClientSyncUseCase .map((file) => file.path) .take(10) .toList(), - 'extraFiles': extraFiles.map((file) => file.path).take(10).toList(), + 'extraFiles': extraFiles.take(10).toList(), }, ), ); final firstProblemPath = mismatchedFiles.isNotEmpty ? mismatchedFiles.first.path - : extraFiles.first.path; + : extraFiles.isNotEmpty + ? extraFiles.first + : 'buildHash'; throw LauncherSyncException( 'Client verification failed after update: $firstProblemPath. ' 'See ${_launcherLogService.logHint(installationDir)}.', @@ -508,15 +485,6 @@ class ClientSyncUseCase } } - /// Файлы, которые не будут удалены во время проверки файлов. Например, пользовательские - /// аддоны, скриншоты и т.д. - bool _isIgnored(String filePath) { - final normalizedPath = filePath.replaceAll('\\', '/').toLowerCase(); - return Config.ignoredVerificationDirectories.any( - (pattern) => normalizedPath.contains(pattern.toLowerCase()), - ); - } - Future _verifyDownloadedFile({ required String installationDir, required ClientManifestFile file, diff --git a/lib/features/launcher/data/game_installation_service.dart b/lib/features/launcher/data/game_installation_service.dart index 8b3119d..aace1e6 100644 --- a/lib/features/launcher/data/game_installation_service.dart +++ b/lib/features/launcher/data/game_installation_service.dart @@ -43,6 +43,10 @@ class GameInstallationService { InstallationScanProgressCallback? onProgress, FutureOr Function()? isCancelled, }) async { + // Validate the entire manifest before scanning or removing any files. + for (final file in manifest.files) { + resolveClientPath(installationDir, file.path); + } final rootDirectory = Directory(installationDir); if (!await rootDirectory.exists()) { return ClientInstallationSnapshot.fromFiles(const []); @@ -103,7 +107,12 @@ class GameInstallationService { } if (type == 'done') { - final snapshot = ClientInstallationSnapshot.fromFiles(files); + final snapshot = ClientInstallationSnapshot.fromFiles( + files, + stalePaths: (message['stalePaths'] as List) + .whereType() + .toList(), + ); completer.complete(snapshot); await cleanup(killIsolate: false); } @@ -138,7 +147,6 @@ class GameInstallationService { 'installationDir': installationDir, 'hashCachePath': hashCachePath, 'cachedHashes': cachedHashes, - 'ignoredDirectories': Config.ignoredVerificationDirectories.toList(), 'serverFiles': manifest.files.map((f) => f.path).toList(), }, onError: errorPort.sendPort, @@ -182,9 +190,20 @@ class GameInstallationService { String installationDir, String relativePath, ) async { - final file = File(resolveClientPath(installationDir, relativePath)); - if (await file.exists()) { - await file.delete(); + final filePath = resolveClientPath(installationDir, relativePath); + final type = await FileSystemEntity.type(filePath, followLinks: false); + if (type == FileSystemEntityType.notFound) { + return; + } + await _ensureParentInsideInstallation(installationDir, filePath); + if (type == FileSystemEntityType.link) { + await Link(filePath).delete(); + } else if (type == FileSystemEntityType.directory) { + // Stale directories are collected after their children. Never recursively + // delete a directory: files created since the scan must not be lost. + await Directory(filePath).delete(); + } else if (type == FileSystemEntityType.file) { + await File(filePath).delete(); } } @@ -285,6 +304,8 @@ class GameInstallationService { 'WOW_PASS': authorization.ticket, 'WOW_HOST': authAddress, 'WOW_DATA': dataPath, + // Let the client select Data/ruRU independently of the launcher's parent environment. + 'WOW_LOCALE': 'auto', 'BENILLA_HOME': p.join(rootPath, 'benilla-config'), }, ); @@ -394,7 +415,9 @@ class GameInstallationService { if (pathSegments.isEmpty || pathSegments.any((segment) => segment == '..') || - p.posix.isAbsolute(normalizedPath)) { + pathSegments.any((segment) => segment.contains(':')) || + p.posix.isAbsolute(normalizedPath) || + p.windows.isAbsolute(normalizedPath)) { throw LauncherSyncException('Invalid client file path: $relativePath'); } @@ -460,28 +483,19 @@ Future _scanInstallationIsolateMain(Map message) async { final cachedHashes = (message['cachedHashes'] as Map).map( (key, value) => MapEntry(key.toString(), value), ); - final ignoredDirectories = (message['ignoredDirectories'] as List) - .whereType() - .map((entry) => entry.toLowerCase()) - .toSet(); final serverFiles = (message['serverFiles'] as List) .whereType() .toSet(); final rootDirectory = Directory(installationDir); if (!await rootDirectory.exists()) { - sendPort.send({'type': 'done'}); + sendPort.send({'type': 'done', 'stalePaths': []}); return; } final pendingFiles = <_SerializablePendingFile>[]; - await _collectFilesInIsolate( - rootDirectory, - rootDirectory.path, - pendingFiles, - ignoredDirectories, - serverFiles, - ); + await _collectManifestFiles(rootDirectory.path, pendingFiles, serverFiles); + final stalePaths = await _collectStalePaths(rootDirectory, serverFiles); pendingFiles.sort( (left, right) => left.relativePath.compareTo(right.relativePath), @@ -519,7 +533,7 @@ Future _scanInstallationIsolateMain(Map message) async { } await _writeHashCache(hashCachePath, updatedCacheEntries); - sendPort.send({'type': 'done'}); + sendPort.send({'type': 'done', 'stalePaths': stalePaths}); } Future _resolveFileHash( @@ -544,26 +558,35 @@ Future _resolveFileHash( return digest.toString(); } -Future _collectFilesInIsolate( - Directory directory, +Future _collectManifestFiles( String rootPath, List<_SerializablePendingFile> pendingFiles, - Set ignoredDirectories, Set serverFiles, ) async { + final installationService = GameInstallationService(); + final resolvedRoot = await Directory(rootPath).resolveSymbolicLinks(); for (final path in serverFiles) { - final file = File('$rootPath/$path'); + final relativePath = normalizeClientPath(path); + final file = File( + installationService.resolveClientPath(rootPath, relativePath), + ); if (!await file.exists()) { continue; } + final resolvedFile = await file.resolveSymbolicLinks(); + if (!p.isWithin(resolvedRoot, resolvedFile)) { + throw LauncherSyncException( + 'Client file points outside installation: $path', + ); + } final stat = await file.stat(); pendingFiles.add( _SerializablePendingFile( path: file.path, - relativePath: normalizeClientPath(p.relative(path)), + relativePath: relativePath, size: stat.size, modifiedMs: stat.modified.millisecondsSinceEpoch, ), @@ -571,6 +594,88 @@ Future _collectFilesInIsolate( } } +Future _ensureParentInsideInstallation( + String installationDir, + String filePath, +) async { + final rootPath = await Directory(installationDir).resolveSymbolicLinks(); + final parentPath = await File(filePath).parent.resolveSymbolicLinks(); + if (!p.equals(rootPath, parentPath) && !p.isWithin(rootPath, parentPath)) { + throw LauncherSyncException( + 'Client path points outside installation: $filePath', + ); + } +} + +Future> _collectStalePaths( + Directory rootDirectory, + Set serverFiles, +) async { + String pathKey(String path) { + final normalized = normalizeClientPath(path); + return Platform.isWindows ? normalized.toLowerCase() : normalized; + } + + final manifestPaths = serverFiles.map(pathKey).toSet(); + final manifestDirectories = {}; + for (final filePath in manifestPaths) { + var directory = p.posix.dirname(filePath); + while (directory != '.') { + manifestDirectories.add(directory); + directory = p.posix.dirname(directory); + } + } + final stalePaths = []; + + String relativePath(FileSystemEntity entity) => + normalizeClientPath(p.relative(entity.path, from: rootDirectory.path)); + + Future collectStaleDirectory(Directory directory) async { + await for (final entity in directory.list(followLinks: false)) { + final path = relativePath(entity); + if (entity is Directory) { + await collectStaleDirectory(entity); + if (!manifestDirectories.contains(pathKey(path))) { + stalePaths.add(path); + } + } else if (!manifestPaths.contains(pathKey(path)) && + !(entity is Link && manifestDirectories.contains(pathKey(path)))) { + stalePaths.add(path); + } + } + } + + Future collectClientRoot( + Directory directory, { + bool nested = false, + }) async { + await for (final entity in directory.list(followLinks: false)) { + final name = p.basename(entity.path).toLowerCase(); + if (entity is Directory) { + if (name == 'data') { + await collectStaleDirectory(entity); + } else if (name == 'extentions') { + await collectStaleDirectory(entity); + if (!manifestDirectories.contains(pathKey(relativePath(entity)))) { + stalePaths.add(relativePath(entity)); + } + } else if (!nested && name == 'wow') { + await collectClientRoot(entity, nested: true); + } + } else if ((name == 'wow.exe' || + name.endsWith('.dll') || + (entity is Link && name == 'extentions')) && + !manifestPaths.contains(pathKey(relativePath(entity))) && + !manifestDirectories.contains(pathKey(relativePath(entity)))) { + stalePaths.add(relativePath(entity)); + } + } + } + + await collectClientRoot(rootDirectory); + return stalePaths; +} + Future _writeHashCache( String hashCachePath, Map> entries, diff --git a/lib/features/launcher/domain/entities/client_installation_snapshot.dart b/lib/features/launcher/domain/entities/client_installation_snapshot.dart index f4e39b6..0f89530 100644 --- a/lib/features/launcher/domain/entities/client_installation_snapshot.dart +++ b/lib/features/launcher/domain/entities/client_installation_snapshot.dart @@ -5,15 +5,24 @@ final class ClientInstallationSnapshot { final List files; final String buildHash; + /// Unmanaged cleanup paths, with directories ordered after their children. + /// These paths do not contribute to [buildHash]. + final List stalePaths; + const ClientInstallationSnapshot({ required this.files, required this.buildHash, + this.stalePaths = const [], }); - factory ClientInstallationSnapshot.fromFiles(List files) { + factory ClientInstallationSnapshot.fromFiles( + List files, { + List stalePaths = const [], + }) { return ClientInstallationSnapshot( files: files, buildHash: computeBuildHash(files), + stalePaths: stalePaths, ); } diff --git a/pubspec.yaml b/pubspec.yaml index 596e317..c2051af 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.5+8 +version: 1.0.7+10 environment: sdk: ">=3.10.0" diff --git a/test/features/launcher/application/client_sync_use_case_test.dart b/test/features/launcher/application/client_sync_use_case_test.dart index 2c1fca5..7032bc1 100644 --- a/test/features/launcher/application/client_sync_use_case_test.dart +++ b/test/features/launcher/application/client_sync_use_case_test.dart @@ -1,8 +1,9 @@ import 'dart:async'; -// import 'dart:collection'; +import 'dart:collection'; +import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; -// import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart'; +import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart'; import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart'; import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart'; import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart'; @@ -10,140 +11,275 @@ import 'package:moonwell_launcher/features/launcher/data/launcher_log_service.da import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart'; -// import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart'; +import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart'; -// import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart'; +import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart'; void main() { group('ClientSyncUseCase', () { - // test( - // 'completes without downloads when local snapshot matches manifest', - // () async { - // final manifest = _manifestFromFiles([ - // const ClientManifestFile( - // path: 'Wow.exe', - // size: 5, - // sha256: 'wow-hash', - // ), - // const ClientManifestFile( - // path: 'Data/common.MPQ', - // size: 10, - // sha256: 'data-hash', - // ), - // ]); - // final matchingSnapshot = ClientInstallationSnapshot.fromFiles([ - // const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'), - // const LocalClientFile( - // path: 'Data/common.MPQ', - // size: 10, - // sha256: 'data-hash', - // ), - // ]); + test( + 'completes without downloads when local snapshot matches manifest', + () async { + final manifest = _manifestFromFiles([ + const ClientManifestFile( + path: 'MoonWell.exe', + size: 5, + sha256: 'wow-hash', + ), + const ClientManifestFile( + path: 'Data/common.MPQ', + size: 10, + sha256: 'data-hash', + ), + ]); + final matchingSnapshot = ClientInstallationSnapshot.fromFiles([ + const LocalClientFile( + path: 'MoonWell.exe', + size: 5, + sha256: 'wow-hash', + ), + const LocalClientFile( + path: 'Data/common.MPQ', + size: 10, + sha256: 'data-hash', + ), + ]); - // final api = _FakeLauncherApiClient(manifest); - // final installationService = _FakeGameInstallationService([ - // matchingSnapshot, - // ]); - // final logger = _FakeLauncherLogService(); - // final useCase = ClientSyncUseCase( - // launcherApiClient: api, - // installationService: installationService, - // launcherLogService: logger, - // ); + final api = _FakeLauncherApiClient(manifest); + final installationService = _FakeGameInstallationService([ + matchingSnapshot, + ]); + final logger = _FakeLauncherLogService(); + final useCase = ClientSyncUseCase( + launcherApiClient: api, + installationService: installationService, + launcherLogService: logger, + ); - // final statuses = await useCase - // .call( - // ClientSyncUseCaseInput( - // request: ClientSyncRequest( - // installationDir: 'C:/MoonWell', - // accessToken: 'token', - // manifest: manifest, - // ), - // ), - // ) - // .toList(); + final statuses = await useCase + .call( + ClientSyncUseCaseInput( + request: ClientSyncRequest( + installationDir: 'C:/MoonWell', + accessToken: 'token', + manifest: manifest, + ), + ), + ) + .toList(); - // expect(statuses.last.stage, ClientSyncStage.completed); - // expect(statuses.last.message, 'Клиент обновлён.'); - // expect(api.downloadedPaths, isEmpty); - // expect(installationService.deletedRelativeFiles, isEmpty); - // expect(logger.errorMessages, isEmpty); - // }, - // ); + expect(statuses.last.stage, ClientSyncStage.completed); + expect(statuses.last.message, 'Клиент обновлён.'); + expect(api.downloadedPaths, isEmpty); + expect(installationService.deletedRelativeFiles, isEmpty); + expect(logger.errorMessages, isEmpty); + }, + ); - // test( - // 'removes stale files and downloads changed files before final verify', - // () async { - // final manifest = _manifestFromFiles([ - // const ClientManifestFile( - // path: 'Wow.exe', - // size: 5, - // sha256: 'wow-hash', - // ), - // const ClientManifestFile( - // path: 'Data/common.MPQ', - // size: 10, - // sha256: 'data-hash', - // ), - // ]); - // final initialSnapshot = ClientInstallationSnapshot.fromFiles([ - // const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'old-wow'), - // const LocalClientFile(path: 'legacy.txt', size: 2, sha256: 'legacy'), - // ]); - // final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([ - // const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'), - // const LocalClientFile( - // path: 'Data/common.MPQ', - // size: 10, - // sha256: 'data-hash', - // ), - // ]); + test( + 'removes stale files and downloads changed files before final verify', + () async { + final manifest = _manifestFromFiles([ + const ClientManifestFile( + path: 'MoonWell.exe', + size: 5, + sha256: 'wow-hash', + ), + const ClientManifestFile( + path: 'Data/common.MPQ', + size: 10, + sha256: 'data-hash', + ), + ]); + final initialSnapshot = ClientInstallationSnapshot.fromFiles( + [ + const LocalClientFile( + path: 'MoonWell.exe', + size: 5, + sha256: 'old-wow', + ), + ], + stalePaths: ['Data/legacy.MPQ', 'WoW.exe', 'old.dll'], + ); + final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([ + const LocalClientFile( + path: 'MoonWell.exe', + size: 5, + sha256: 'wow-hash', + ), + const LocalClientFile( + path: 'Data/common.MPQ', + size: 10, + sha256: 'data-hash', + ), + ]); - // final api = _FakeLauncherApiClient(manifest); - // final installationService = - // _FakeGameInstallationService([initialSnapshot, verifiedSnapshot]) - // ..fileSizesByPath['C:/MoonWell/Wow.exe.moonwell.part'] = 5 - // ..computedHashesByPath['C:/MoonWell/Wow.exe.moonwell.part'] = - // 'wow-hash' - // ..fileSizesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] = - // 10 - // ..computedHashesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] = - // 'data-hash'; - // final logger = _FakeLauncherLogService(); - // final useCase = ClientSyncUseCase( - // launcherApiClient: api, - // installationService: installationService, - // launcherLogService: logger, - // ); + final api = _FakeLauncherApiClient(manifest); + final installationService = + _FakeGameInstallationService([initialSnapshot, verifiedSnapshot]) + ..fileSizesByPath['C:/MoonWell/MoonWell.exe.moonwell.part'] = 5 + ..computedHashesByPath['C:/MoonWell/MoonWell.exe.moonwell.part'] = + 'wow-hash' + ..fileSizesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] = + 10 + ..computedHashesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] = + 'data-hash'; + final logger = _FakeLauncherLogService(); + final useCase = ClientSyncUseCase( + launcherApiClient: api, + installationService: installationService, + launcherLogService: logger, + ); - // final statuses = await useCase - // .call( - // ClientSyncUseCaseInput( - // request: ClientSyncRequest( - // installationDir: 'C:/MoonWell', - // accessToken: 'token', - // manifest: manifest, - // ), - // ), - // ) - // .toList(); + final statuses = await useCase + .call( + ClientSyncUseCaseInput( + request: ClientSyncRequest( + installationDir: 'C:/MoonWell', + accessToken: 'token', + manifest: manifest, + ), + ), + ) + .toList(); - // expect( - // statuses.map((status) => status.stage), - // containsAll([ - // ClientSyncStage.removingStaleFiles, - // ClientSyncStage.downloadingFiles, - // ClientSyncStage.verifyingInstallation, - // ClientSyncStage.completed, - // ]), - // ); - // expect(installationService.deletedRelativeFiles, ['legacy.txt']); - // expect(api.downloadedPaths, ['Wow.exe', 'Data/common.MPQ']); - // expect(statuses.last.message, 'Клиент готов к игре.'); - // expect(logger.errorMessages, isEmpty); - // }, - // ); + expect( + statuses.map((status) => status.stage), + containsAll([ + ClientSyncStage.removingStaleFiles, + ClientSyncStage.downloadingFiles, + ClientSyncStage.verifyingInstallation, + ClientSyncStage.completed, + ]), + ); + expect(installationService.deletedRelativeFiles, [ + 'Data/legacy.MPQ', + 'WoW.exe', + 'old.dll', + ]); + expect(api.downloadedPaths, ['MoonWell.exe', 'Data/common.MPQ']); + expect(statuses.last.message, 'Клиент готов к игре.'); + expect(logger.errorMessages, isEmpty); + }, + ); + + test('cleans an up-to-date installation without downloading', () async { + final root = await Directory.systemTemp.createTemp('moonwell_sync_'); + addTearDown(() => root.delete(recursive: true)); + final service = GameInstallationService(); + final retained = ['MoonWell.exe', 'Data/common.MPQ', 'required.dll']; + final obsolete = [ + 'Data/old/patch.MPQ', + 'WoW.exe', + 'unused.DLL', + 'Extentions/bin/extension.dll', + ]; + for (final path in [ + ...retained, + ...obsolete, + 'WTF/Config.wtf', + 'realmlist.wtf', + ]) { + final file = File(service.resolveClientPath(root.path, path)); + await file.parent.create(recursive: true); + await file.writeAsString(path); + } + final entries = []; + for (final path in retained) { + final filePath = service.resolveClientPath(root.path, path); + entries.add( + ClientManifestFile( + path: path, + size: (await File(filePath).stat()).size, + sha256: await service.computeSha256(filePath), + ), + ); + } + final manifest = _manifestFromFiles(entries); + final api = _FakeLauncherApiClient(manifest); + final useCase = ClientSyncUseCase( + launcherApiClient: api, + installationService: service, + launcherLogService: _FakeLauncherLogService(), + ); + final statuses = await useCase + .call( + ClientSyncUseCaseInput( + request: ClientSyncRequest( + installationDir: root.path, + accessToken: 'token', + manifest: manifest, + ), + ), + ) + .toList(); + + expect(statuses.last.stage, ClientSyncStage.completed); + expect(statuses.last.localBuildHash, manifest.buildHash); + expect( + statuses.map((status) => status.stage), + contains(ClientSyncStage.removingStaleFiles), + ); + expect(api.downloadedPaths, isEmpty); + for (final path in [ + ...obsolete, + 'Data/old', + 'Extentions/bin', + 'Extentions', + ]) { + expect( + await FileSystemEntity.type( + service.resolveClientPath(root.path, path), + followLinks: false, + ), + FileSystemEntityType.notFound, + reason: path, + ); + } + for (final path in [...retained, 'WTF/Config.wtf', 'realmlist.wtf']) { + expect( + await File(service.resolveClientPath(root.path, path)).readAsString(), + path, + ); + } + }); + + test('fails final verification when stale files remain', () async { + final manifest = _manifestFromFiles([ + const ClientManifestFile(path: 'MoonWell.exe', size: 5, sha256: 'hash'), + ]); + final snapshot = ClientInstallationSnapshot.fromFiles( + [const LocalClientFile(path: 'MoonWell.exe', size: 5, sha256: 'hash')], + stalePaths: ['Data/extra.MPQ'], + ); + final service = _FakeGameInstallationService([snapshot, snapshot]); + final useCase = ClientSyncUseCase( + launcherApiClient: _FakeLauncherApiClient(manifest), + installationService: service, + launcherLogService: _FakeLauncherLogService(), + ); + await expectLater( + useCase + .call( + ClientSyncUseCaseInput( + request: ClientSyncRequest( + installationDir: 'C:/MoonWell', + accessToken: 'token', + manifest: manifest, + ), + ), + ) + .drain(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Data/extra.MPQ'), + ), + ), + ); + }); test( 'preserves failed temporary file and returns a log hint on verification mismatch', @@ -244,7 +380,10 @@ class _FakeLauncherApiClient extends LauncherApiClient { } class _FakeGameInstallationService extends GameInstallationService { - _FakeGameInstallationService(List snapshots); + _FakeGameInstallationService(List snapshots) + : _snapshots = Queue.of(snapshots); + + final Queue _snapshots; final List ensuredParentDirectories = []; final List deletedRelativeFiles = []; @@ -254,34 +393,35 @@ class _FakeGameInstallationService extends GameInstallationService { final Map computedHashesByPath = {}; final Map fileSizesByPath = {}; - // @override - // Future scanInstallation( - // String installationDir, { - // InstallationScanProgressCallback? onProgress, - // FutureOr Function()? isCancelled, - // }) async { - // final snapshot = _snapshots.removeFirst(); - // var processedFiles = 0; - // var processedBytes = 0; + @override + Future scanInstallation( + String installationDir, { + required ClientManifest manifest, + InstallationScanProgressCallback? onProgress, + FutureOr Function()? isCancelled, + }) async { + final snapshot = _snapshots.removeFirst(); + var processedFiles = 0; + var processedBytes = 0; - // for (final file in snapshot.files) { - // processedFiles += 1; - // processedBytes += file.size; - // onProgress?.call( - // DownloadProgress( - // speed: 0, - // downloaded: processedBytes, - // total: snapshot.files.fold(0, (sum, item) => sum + item.size), - // eta: Duration.zero, - // ), - // file.path, - // processedFiles, - // snapshot.files.length, - // ); - // } + for (final file in snapshot.files) { + processedFiles += 1; + processedBytes += file.size; + onProgress?.call( + DownloadProgress( + speed: 0, + downloaded: processedBytes, + total: snapshot.files.fold(0, (sum, item) => sum + item.size), + eta: Duration.zero, + ), + file.path, + processedFiles, + snapshot.files.length, + ); + } - // return snapshot; - // } + return snapshot; + } @override Future deleteRelativeFile( diff --git a/test/features/launcher/data/game_installation_service_test.dart b/test/features/launcher/data/game_installation_service_test.dart index d9a7ae2..985920a 100644 --- a/test/features/launcher/data/game_installation_service_test.dart +++ b/test/features/launcher/data/game_installation_service_test.dart @@ -1,9 +1,9 @@ import 'dart:io'; +import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; -// import 'package:moonwell_launcher/config.dart'; import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart'; -// import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart'; +import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart'; @@ -52,37 +52,287 @@ void main() { } }); - // test('scanInstallation ignores excluded directories', () async { - // final snapshot = await service.scanInstallation(rootDirectory.path); - // final paths = snapshot.files.map((file) => file.path).toList()..sort(); + test( + 'finds only unmanaged Data files and legacy client binaries', + () async { + final retainedPaths = [ + 'MoonWell.exe', + 'Data/common.MPQ', + 'Data/ruRU/realmlist.wtf', + 'Data/Addons/required.MPQ', + 'required.DLL', + 'WoW/required.dll', + 'WoW/Data/common.MPQ', + ]; + final staleFiles = [ + 'Data/old.MPQ', + 'Data/ruRU/old.MPQ', + 'Data/Addons/extra.MPQ', + 'Data/Cache/old.bin', + 'Data/empty/old.MPQ', + 'Data/common.MPQ.moonwell.part', + 'Data/broken.MPQ.moonwell.part.failed', + 'WoW.exe', + 'old.DLL', + 'WoW/WoW.exe', + 'WoW/old.dll', + 'WoW/Data/old.MPQ', + 'Extentions/bin/extension.dll', + 'WoW/Extentions/old.txt', + ]; + final untouchedPaths = [ + 'Cache/cache.bin', + 'Logs/launcher.log', + 'WTF/Config.wtf', + 'Errors/error.dll', + 'Screenshots/shot.dll', + 'benilla-config/settings.dll', + '.moonwell_launcher/local.dll', + 'Interface/AddOns/custom/addon.dll', + 'notes.txt', + 'Other.exe', + 'realmlist.wtf', + ]; + for (final path in [ + ...retainedPaths, + ...staleFiles, + ...untouchedPaths, + ]) { + final file = File( + service.resolveClientPath(rootDirectory.path, path), + ); + await file.parent.create(recursive: true); + await file.writeAsString(path); + } + await Directory( + service.resolveClientPath(rootDirectory.path, 'Data/unused/nested'), + ).create(recursive: true); + await Directory( + service.resolveClientPath(rootDirectory.path, 'Extentions/empty'), + ).create(recursive: true); + final manifest = await _manifestForPaths( + service, + rootDirectory, + retainedPaths, + ); - // expect(paths, ['Data/common.MPQ', 'Wow.exe']); - // expect(snapshot.buildHash, computeBuildHash(snapshot.files)); - // }); + final snapshot = await service.scanInstallation( + rootDirectory.path, + manifest: manifest, + ); - // test( - // 'scanInstallation creates launcher hash cache outside verification set', - // () async { - // await service.scanInstallation(rootDirectory.path); + expect( + snapshot.files.map((file) => file.path), + unorderedEquals(retainedPaths), + ); + expect(snapshot.buildHash, manifest.buildHash); + expect( + snapshot.stalePaths, + unorderedEquals([ + ...staleFiles, + 'Data/Cache', + 'Data/empty', + 'Data/unused/nested', + 'Data/unused', + 'Extentions/bin', + 'Extentions/empty', + 'Extentions', + 'WoW/Extentions', + ]), + ); + for (final path in snapshot.stalePaths) { + await service.deleteRelativeFile(rootDirectory.path, path); + } + for (final path in [...retainedPaths, ...untouchedPaths]) { + expect( + await File( + service.resolveClientPath(rootDirectory.path, path), + ).exists(), + isTrue, + reason: path, + ); + } + for (final path in snapshot.stalePaths) { + expect( + await FileSystemEntity.type( + service.resolveClientPath(rootDirectory.path, path), + followLinks: false, + ), + FileSystemEntityType.notFound, + reason: path, + ); + } - // final cacheFile = File(service.getHashCachePath(rootDirectory.path)); - // expect(await cacheFile.exists(), isTrue); + final verified = await service.scanInstallation( + rootDirectory.path, + manifest: manifest, + ); + expect(verified.stalePaths, isEmpty); + expect(verified.buildHash, manifest.buildHash); + expect( + await File(service.getHashCachePath(rootDirectory.path)).exists(), + isTrue, + ); + }, + ); - // final secondSnapshot = await service.scanInstallation( - // rootDirectory.path, - // ); - // final paths = secondSnapshot.files.map((file) => file.path).toList() - // ..sort(); + test('removes Data links without touching their targets', () async { + final outside = await Directory.systemTemp.createTemp( + 'moonwell_outside_', + ); + addTearDown(() => outside.delete(recursive: true)); + final outsideFile = File( + '${outside.path}${Platform.pathSeparator}keep.dll', + ); + await outsideFile.writeAsString('keep'); + final link = Link( + service.resolveClientPath(rootDirectory.path, 'Data/external'), + ); + await link.create(outside.path); + final manifest = await _manifestForPaths(service, rootDirectory, [ + 'Data/common.MPQ', + ]); - // expect(paths, ['Data/common.MPQ', 'Wow.exe']); - // expect( - // await Directory( - // '${rootDirectory.path}${Platform.pathSeparator}${Config.launcherMetadataDirectoryName}', - // ).exists(), - // isTrue, - // ); - // }, - // ); + final snapshot = await service.scanInstallation( + rootDirectory.path, + manifest: manifest, + ); + expect(snapshot.stalePaths, ['Data/external']); + await service.deleteRelativeFile(rootDirectory.path, 'Data/external'); + expect(await link.exists(), isFalse); + expect(await outsideFile.readAsString(), 'keep'); + }); + + test('retains a directory link needed by manifest files', () async { + final target = Directory( + service.resolveClientPath(rootDirectory.path, 'shared-data'), + ); + await target.create(); + await File( + '${target.path}${Platform.pathSeparator}required.MPQ', + ).writeAsString('required'); + final link = Link( + service.resolveClientPath(rootDirectory.path, 'Data/linked'), + ); + await link.create(target.path); + final manifest = await _manifestForPaths(service, rootDirectory, [ + 'Data/common.MPQ', + 'Data/linked/required.MPQ', + ]); + final snapshot = await service.scanInstallation( + rootDirectory.path, + manifest: manifest, + ); + expect(snapshot.stalePaths, isEmpty); + expect(snapshot.buildHash, manifest.buildHash); + expect(await link.exists(), isTrue); + }); + + test( + 'rejects deleting through a directory link outside installation', + () async { + final outside = await Directory.systemTemp.createTemp( + 'moonwell_outside_', + ); + addTearDown(() => outside.delete(recursive: true)); + final outsideFile = File( + '${outside.path}${Platform.pathSeparator}keep.dll', + ); + await outsideFile.writeAsString('keep'); + final link = Link( + service.resolveClientPath(rootDirectory.path, 'Data/external'), + ); + await link.create(outside.path); + await expectLater( + service.deleteRelativeFile( + rootDirectory.path, + 'Data/external/keep.dll', + ), + throwsA(isA()), + ); + expect(await outsideFile.readAsString(), 'keep'); + }, + ); + + test( + 'preserves Windows manifest files despite path case differences', + () async { + final file = File( + service.resolveClientPath( + rootDirectory.path, + 'Data/ruRU/required.DLL', + ), + ); + await file.parent.create(recursive: true); + await file.writeAsString('required'); + final manifest = await _manifestForPaths(service, rootDirectory, [ + 'data/ruru/required.dll', + ]); + final snapshot = await service.scanInstallation( + rootDirectory.path, + manifest: manifest, + ); + expect(snapshot.stalePaths, ['Data/common.MPQ']); + expect(snapshot.buildHash, manifest.buildHash); + }, + skip: !Platform.isWindows, + ); + + test('preserves WoW.exe when explicitly present in manifest', () async { + await File( + service.resolveClientPath(rootDirectory.path, 'WoW.exe'), + ).writeAsString('legacy'); + final manifest = await _manifestForPaths(service, rootDirectory, [ + 'WoW.exe', + 'Data/common.MPQ', + ]); + final snapshot = await service.scanInstallation( + rootDirectory.path, + manifest: manifest, + ); + expect(snapshot.stalePaths, isEmpty); + expect(snapshot.buildHash, manifest.buildHash); + }); + + test('rejects invalid manifest paths before scanning', () async { + for (final path in [ + '../outside.dll', + '/outside.dll', + r'C:\outside.dll', + 'C:outside.dll', + 'Data/file:stream', + ]) { + final manifest = ClientManifest.fromJson({ + 'files': [ + {'path': path, 'size': 0, 'sha256': ''}, + ], + }); + await expectLater( + service.scanInstallation(rootDirectory.path, manifest: manifest), + throwsA(isA()), + ); + } + expect( + await File(service.getHashCachePath(rootDirectory.path)).exists(), + isFalse, + ); + }); + + test( + 'never recursively deletes a stale directory with new files', + () async { + final file = File( + service.resolveClientPath(rootDirectory.path, 'Data/old/new.MPQ'), + ); + await file.parent.create(recursive: true); + await file.writeAsString('new'); + await expectLater( + service.deleteRelativeFile(rootDirectory.path, 'Data/old'), + throwsA(isA()), + ); + expect(await file.readAsString(), 'new'); + }, + ); test('clearCache recreates an empty cache directory', () async { await service.clearCache(rootDirectory.path); @@ -180,6 +430,7 @@ void main() { 'WOW_PASS': 'A1B2C3D4E5F6G7H8', 'WOW_HOST': '127.0.0.1:3724', 'WOW_DATA': '${rootDirectory.path}${Platform.pathSeparator}Data', + 'WOW_LOCALE': 'auto', 'BENILLA_HOME': '${rootDirectory.path}${Platform.pathSeparator}benilla-config', }); @@ -238,6 +489,46 @@ void main() { expect(recordingService.environment?['WOW_DATA'], nestedData.path); }); + test('verifies and retains Russian archives before launching', () async { + final paths = [ + 'MoonWell.exe', + 'Data/common.MPQ', + 'Data/ruRU/patch-1.MPQ', + 'Data/ruRU/patch-2.MPQ', + 'Data/ruRU/patch-3.MPQ', + ]; + for (final path in paths.skip(2)) { + final file = File(service.resolveClientPath(rootDirectory.path, path)); + await file.parent.create(recursive: true); + await file.writeAsString('localized archive: $path'); + } + final manifest = await _manifestForPaths(service, rootDirectory, paths); + final snapshot = await service.scanInstallation( + rootDirectory.path, + manifest: manifest, + ); + expect(snapshot.buildHash, manifest.buildHash); + expect(snapshot.stalePaths, isEmpty); + final recordingService = _RecordingGameInstallationService(); + await recordingService.launchGame( + rootDirectory.path, + authorization: _validTicket(), + ); + expect(recordingService.environment?['WOW_LOCALE'], 'auto'); + expect( + recordingService.environment?['WOW_DATA'], + '${rootDirectory.path}${Platform.pathSeparator}Data', + ); + for (final path in paths.skip(2)) { + expect( + await File( + service.resolveClientPath(rootDirectory.path, path), + ).readAsString(), + 'localized archive: $path', + ); + } + }); + test('configured auth address overrides the install realmlist', () async { final configured = _RecordingGameInstallationService( authAddress: 'login.example:3725', @@ -335,6 +626,25 @@ void main() { }); } +Future _manifestForPaths( + GameInstallationService service, + Directory rootDirectory, + List paths, +) async { + final files = >[]; + for (final path in paths) { + final bytes = await File( + service.resolveClientPath(rootDirectory.path, path), + ).readAsBytes(); + files.add({ + 'path': path, + 'size': bytes.length, + 'sha256': sha256.convert(bytes).toString(), + }); + } + return ClientManifest.fromJson({'files': files}); +} + LauncherGameTicket _validTicket() => LauncherGameTicket( account: 'PLAYERONE', ticket: 'A1B2C3D4E5F6G7H8', diff --git a/tool/deploy_launcher.ps1 b/tool/deploy_launcher.ps1 index 4f7bcf9..2099265 100644 --- a/tool/deploy_launcher.ps1 +++ b/tool/deploy_launcher.ps1 @@ -94,9 +94,19 @@ function Invoke-RequiredCommand { [Parameter(Mandatory = $true)][string[]]$Arguments ) - & $Command @Arguments - if ($LASTEXITCODE -ne 0) { - throw "Command failed with exit code ${LASTEXITCODE}: $Command" + # Windows PowerShell represents native stderr as error records even on exit 0. + # OpenSSL writes its normal key-reading notices there; the exit code is the verdict. + $previousPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + & $Command @Arguments + $commandExitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousPreference + } + if ($commandExitCode -ne 0) { + throw "Command failed with exit code ${commandExitCode}: $Command" } } @@ -559,12 +569,9 @@ try { $previousPath = $env:PATH try { $env:PATH = "$gitOpenSslDirectory;$env:PATH" - $signOutput = & $dart run auto_updater:sign_update ` - $installerPath ` - $privateKey 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "Could not sign the installer: $signOutput" - } + $signOutput = Invoke-RequiredCommand -Command $dart -Arguments @( + 'run', 'auto_updater:sign_update', $installerPath, $privateKey + ) 2>&1 } finally { $env:PATH = $previousPath