обновленная синхронизация клиента
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user