обновленная синхронизация клиента

This commit is contained in:
2026-10-04 21:15:18 +04:00
parent d3d19bfaeb
commit 18c8e07744
8 changed files with 749 additions and 249 deletions
+4
View File
@@ -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:
+7
View File
@@ -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
+34 -7
View File
@@ -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
@@ -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<String> _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<void> _removeStaleFiles({
required StreamController<ClientSyncStatus> 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<void> _verifyDownloadedFile({
required String installationDir,
required ClientManifestFile file,
@@ -43,6 +43,10 @@ class GameInstallationService {
InstallationScanProgressCallback? onProgress,
FutureOr<bool> 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<Object?>)
.whereType<String>()
.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<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
final cachedHashes = (message['cachedHashes'] as Map<Object?, Object?>).map(
(key, value) => MapEntry(key.toString(), value),
);
final ignoredDirectories = (message['ignoredDirectories'] as List<Object?>)
.whereType<String>()
.map((entry) => entry.toLowerCase())
.toSet();
final serverFiles = (message['serverFiles'] as List<Object?>)
.whereType<String>()
.toSet();
final rootDirectory = Directory(installationDir);
if (!await rootDirectory.exists()) {
sendPort.send(<String, Object?>{'type': 'done'});
sendPort.send(<String, Object?>{'type': 'done', 'stalePaths': <String>[]});
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<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
}
await _writeHashCache(hashCachePath, updatedCacheEntries);
sendPort.send(<String, Object?>{'type': 'done'});
sendPort.send(<String, Object?>{'type': 'done', 'stalePaths': stalePaths});
}
Future<String> _resolveFileHash(
@@ -544,26 +556,35 @@ Future<String> _resolveFileHash(
return digest.toString();
}
Future<void> _collectFilesInIsolate(
Directory directory,
Future<void> _collectManifestFiles(
String rootPath,
List<_SerializablePendingFile> pendingFiles,
Set<String> ignoredDirectories,
Set<String> 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<void> _collectFilesInIsolate(
}
}
Future<void> _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<List<String>> _collectStalePaths(
Directory rootDirectory,
Set<String> serverFiles,
) async {
String pathKey(String path) {
final normalized = normalizeClientPath(path);
return Platform.isWindows ? normalized.toLowerCase() : normalized;
}
final manifestPaths = serverFiles.map(pathKey).toSet();
final manifestDirectories = <String>{};
for (final filePath in manifestPaths) {
var directory = p.posix.dirname(filePath);
while (directory != '.') {
manifestDirectories.add(directory);
directory = p.posix.dirname(directory);
}
}
final stalePaths = <String>[];
String relativePath(FileSystemEntity entity) =>
normalizeClientPath(p.relative(entity.path, from: rootDirectory.path));
Future<void> 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<void> 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<void> _writeHashCache(
String hashCachePath,
Map<String, Map<String, Object?>> entries,
@@ -5,15 +5,24 @@ final class ClientInstallationSnapshot {
final List<LocalClientFile> files;
final String buildHash;
/// Unmanaged cleanup paths, with directories ordered after their children.
/// These paths do not contribute to [buildHash].
final List<String> stalePaths;
const ClientInstallationSnapshot({
required this.files,
required this.buildHash,
this.stalePaths = const [],
});
factory ClientInstallationSnapshot.fromFiles(List<LocalClientFile> files) {
factory ClientInstallationSnapshot.fromFiles(
List<LocalClientFile> files, {
List<String> stalePaths = const [],
}) {
return ClientInstallationSnapshot(
files: files,
buildHash: computeBuildHash(files),
stalePaths: stalePaths,
);
}
@@ -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 = <ClientManifestFile>[];
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<void>(),
throwsA(
isA<LauncherSyncException>().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<ClientInstallationSnapshot> snapshots);
_FakeGameInstallationService(List<ClientInstallationSnapshot> snapshots)
: _snapshots = Queue.of(snapshots);
final Queue<ClientInstallationSnapshot> _snapshots;
final List<String> ensuredParentDirectories = [];
final List<String> deletedRelativeFiles = [];
@@ -254,34 +383,35 @@ class _FakeGameInstallationService extends GameInstallationService {
final Map<String, String> computedHashesByPath = {};
final Map<String, int?> fileSizesByPath = {};
// @override
// Future<ClientInstallationSnapshot> scanInstallation(
// String installationDir, {
// InstallationScanProgressCallback? onProgress,
// FutureOr<bool> Function()? isCancelled,
// }) async {
// final snapshot = _snapshots.removeFirst();
// var processedFiles = 0;
// var processedBytes = 0;
@override
Future<ClientInstallationSnapshot> scanInstallation(
String installationDir, {
required ClientManifest manifest,
InstallationScanProgressCallback? onProgress,
FutureOr<bool> 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<int>(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<int>(0, (sum, item) => sum + item.size),
eta: Duration.zero,
),
file.path,
processedFiles,
snapshot.files.length,
);
}
// return snapshot;
// }
return snapshot;
}
@override
Future<void> deleteRelativeFile(
@@ -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<LauncherSyncException>()),
);
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<LauncherSyncException>()),
);
}
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<FileSystemException>()),
);
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<ClientManifest> _manifestForPaths(
GameInstallationService service,
Directory rootDirectory,
List<String> paths,
) async {
final files = <Map<String, Object>>[];
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',