From 18c8e077445af8eb4b4f174e57551d7bdce44d26 Mon Sep 17 00:00:00 2001 From: sindoring Date: Sun, 4 Oct 2026 21:15:18 +0400 Subject: [PATCH 1/4] =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=B0=D1=8F=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 + README.md | 7 + docs/launcher_web_api_spec.md | 41 +- .../application/client_sync_use_case.dart | 44 +- .../data/game_installation_service.dart | 143 +++++- .../client_installation_snapshot.dart | 11 +- .../client_sync_use_case_test.dart | 432 ++++++++++++------ .../data/game_installation_service_test.dart | 316 +++++++++++-- 8 files changed, 749 insertions(+), 249 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 738fdde..2e44274 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,10 @@ 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`. + Remove unused Data subdirectories only after their children, without recursive + deletion. Preserve unrelated files and user state outside these areas. - 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..6a1bcfd 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,13 @@ 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. +Cleanup runs even when all manifest files are already up to date. + ## Stack - Flutter desktop diff --git a/docs/launcher_web_api_spec.md b/docs/launcher_web_api_spec.md index 43b21d9..cf65c94 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,35 @@ 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/` +- `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 +247,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 @@ -295,8 +318,12 @@ 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, and DLLs +- 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/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..cb235d2 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(); } } @@ -394,7 +413,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 +481,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 +531,7 @@ Future _scanInstallationIsolateMain(Map message) async { } await _writeHashCache(hashCachePath, updatedCacheEntries); - sendPort.send({'type': 'done'}); + sendPort.send({'type': 'done', 'stalePaths': stalePaths}); } Future _resolveFileHash( @@ -544,26 +556,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 +592,80 @@ 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 collectData(Directory directory) async { + await for (final entity in directory.list(followLinks: false)) { + final path = relativePath(entity); + if (entity is Directory) { + await collectData(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 collectData(entity); + } else if (!nested && name == 'wow') { + await collectClientRoot(entity, nested: true); + } + } else if ((name == 'wow.exe' || name.endsWith('.dll')) && + !manifestPaths.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/test/features/launcher/application/client_sync_use_case_test.dart b/test/features/launcher/application/client_sync_use_case_test.dart index 2c1fca5..b54f5fe 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,265 @@ 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']; + 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']) { + 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 +370,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 +383,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..de0b22a 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,278 @@ 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', + ]; + 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); + 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', + ]), + ); + 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); @@ -335,6 +576,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', -- 2.52.0 From 2fb6774ec6d681b9d58e737ac75394441b2f4ee6 Mon Sep 17 00:00:00 2001 From: sindoring Date: Sat, 10 Oct 2026 16:05:55 +0400 Subject: [PATCH 2/4] Select the installed language pack when launching MoonWell Pass WOW_LOCALE=auto alongside the selected Data directory so inherited locale settings cannot override an installed ruRU pack. Keep language archives in the regular manifest download and verification flow. Document the contract and prepare launcher version 1.0.7+10 for the localization release. Verified on Windows through FVM: full format check and flutter analyze passed; all 76 Flutter tests passed, including manifest retention and verification of Russian archives and the game's child environment. No macOS build was run. --- AGENTS.md | 2 + README.md | 6 +++ docs/launcher_web_api_spec.md | 6 +++ .../data/game_installation_service.dart | 2 + pubspec.yaml | 2 +- .../data/game_installation_service_test.dart | 41 +++++++++++++++++++ 6 files changed, 58 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 2e44274..fa5e116 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,8 @@ or local-only API URLs. plus legacy `WoW.exe` and DLLs directly in the installation root or `WoW`. 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 6a1bcfd..ddf7b15 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,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 cf65c94..f55cbe1 100644 --- a/docs/launcher_web_api_spec.md +++ b/docs/launcher_web_api_spec.md @@ -286,6 +286,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 @@ -297,6 +299,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: diff --git a/lib/features/launcher/data/game_installation_service.dart b/lib/features/launcher/data/game_installation_service.dart index cb235d2..6c5061c 100644 --- a/lib/features/launcher/data/game_installation_service.dart +++ b/lib/features/launcher/data/game_installation_service.dart @@ -304,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'), }, ); 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/data/game_installation_service_test.dart b/test/features/launcher/data/game_installation_service_test.dart index de0b22a..9c37809 100644 --- a/test/features/launcher/data/game_installation_service_test.dart +++ b/test/features/launcher/data/game_installation_service_test.dart @@ -421,6 +421,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', }); @@ -479,6 +480,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', -- 2.52.0 From c802839688b5c304a538c2d0061d302cc546f02f Mon Sep 17 00:00:00 2001 From: sindoring Date: Sat, 10 Oct 2026 16:15:22 +0400 Subject: [PATCH 3/4] Judge deployment commands by exit status on Windows PowerShell OpenSSL prints normal DSA key notices to stderr. Windows PowerShell treated those notices as terminating errors under Stop and aborted before signing. Keep native calls under Continue, then reject nonzero exit codes explicitly. Use the same invocation helper for installer signing. Verified on Windows: a native exit 0 with stderr succeeds, exit 7 is rejected, and the error preference is restored. The full production dry run passed its format/analyze checks, all 76 tests, the Windows build, Inno Setup packaging, DSA key matching and installer-signature verification for 1.0.7+10. --- docs/production_deploy.md | 4 ++++ tool/deploy_launcher.ps1 | 25 ++++++++++++++++--------- 2 files changed, 20 insertions(+), 9 deletions(-) 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/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 -- 2.52.0 From 7799b33239d0a2457d4429d2af096c60c3978b74 Mon Sep 17 00:00:00 2001 From: sindoring Date: Sat, 10 Oct 2026 16:36:51 +0400 Subject: [PATCH 4/4] Clean obsolete Extentions during client synchronization Include the prepared cleanup of both client roots while preserving manifest-listed files and unrelated user state. Verified on Windows through FVM: full formatting and analysis, all 76 tests, production dry run, x64 build and DSA signature verification. --- AGENTS.md | 2 ++ README.md | 2 ++ docs/launcher_web_api_spec.md | 6 +++++- .../data/game_installation_service.dart | 18 +++++++++++++----- .../application/client_sync_use_case_test.dart | 14 ++++++++++++-- .../data/game_installation_service_test.dart | 9 +++++++++ 6 files changed, 43 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa5e116..61a3ec8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,8 @@ or local-only API URLs. - 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. diff --git a/README.md b/README.md index ddf7b15..e31ad74 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ During synchronization, the manifest is authoritative for `Data/` and 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 diff --git a/docs/launcher_web_api_spec.md b/docs/launcher_web_api_spec.md index f55cbe1..196c762 100644 --- a/docs/launcher_web_api_spec.md +++ b/docs/launcher_web_api_spec.md @@ -173,6 +173,9 @@ 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 @@ -325,7 +328,8 @@ The automated tests cover: - deterministic `buildHash` - path normalization used by `buildHash` - preservation of user state and unrelated unmanaged files during cleanup -- removal of unmanaged Data files, unused subdirectories, WoW.exe, and DLLs +- 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 diff --git a/lib/features/launcher/data/game_installation_service.dart b/lib/features/launcher/data/game_installation_service.dart index 6c5061c..aace1e6 100644 --- a/lib/features/launcher/data/game_installation_service.dart +++ b/lib/features/launcher/data/game_installation_service.dart @@ -630,11 +630,11 @@ Future> _collectStalePaths( String relativePath(FileSystemEntity entity) => normalizeClientPath(p.relative(entity.path, from: rootDirectory.path)); - Future collectData(Directory directory) async { + Future collectStaleDirectory(Directory directory) async { await for (final entity in directory.list(followLinks: false)) { final path = relativePath(entity); if (entity is Directory) { - await collectData(entity); + await collectStaleDirectory(entity); if (!manifestDirectories.contains(pathKey(path))) { stalePaths.add(path); } @@ -653,12 +653,20 @@ Future> _collectStalePaths( final name = p.basename(entity.path).toLowerCase(); if (entity is Directory) { if (name == 'data') { - await collectData(entity); + 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')) && - !manifestPaths.contains(pathKey(relativePath(entity)))) { + } 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)); } } 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 b54f5fe..7032bc1 100644 --- a/test/features/launcher/application/client_sync_use_case_test.dart +++ b/test/features/launcher/application/client_sync_use_case_test.dart @@ -169,7 +169,12 @@ void main() { 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']; + final obsolete = [ + 'Data/old/patch.MPQ', + 'WoW.exe', + 'unused.DLL', + 'Extentions/bin/extension.dll', + ]; for (final path in [ ...retained, ...obsolete, @@ -217,7 +222,12 @@ void main() { contains(ClientSyncStage.removingStaleFiles), ); expect(api.downloadedPaths, isEmpty); - for (final path in [...obsolete, 'Data/old']) { + for (final path in [ + ...obsolete, + 'Data/old', + 'Extentions/bin', + 'Extentions', + ]) { expect( await FileSystemEntity.type( service.resolveClientPath(root.path, path), diff --git a/test/features/launcher/data/game_installation_service_test.dart b/test/features/launcher/data/game_installation_service_test.dart index 9c37809..985920a 100644 --- a/test/features/launcher/data/game_installation_service_test.dart +++ b/test/features/launcher/data/game_installation_service_test.dart @@ -77,6 +77,8 @@ void main() { 'WoW/WoW.exe', 'WoW/old.dll', 'WoW/Data/old.MPQ', + 'Extentions/bin/extension.dll', + 'WoW/Extentions/old.txt', ]; final untouchedPaths = [ 'Cache/cache.bin', @@ -105,6 +107,9 @@ void main() { 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, @@ -129,6 +134,10 @@ void main() { 'Data/empty', 'Data/unused/nested', 'Data/unused', + 'Extentions/bin', + 'Extentions/empty', + 'Extentions', + 'WoW/Extentions', ]), ); for (final path in snapshot.stalePaths) { -- 2.52.0