Synchronize the client and select installed language packs (#3)
The launcher synchronizes the current native client, retains manifest-listed ruRU archives and launches the game with WOW_LOCALE=auto. Includes the prepared cleanup of obsolete Extentions directories in both client roots while preserving manifest-required files and unrelated user state. Version: 1.0.7+10. Deployment now judges native OpenSSL commands by their exit codes while retaining key and signature verification. Verified on Windows through FVM: full format/analyze, all 76 tests, production dry run, x64 runtime imports and DSA installer signature. The original dirty checkout was preserved. Publication follows the localized client release.
This commit was merged in pull request #3.
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
// import 'package:moonwell_launcher/config.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
|
||||
// import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
|
||||
|
||||
@@ -52,37 +52,287 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
// test('scanInstallation ignores excluded directories', () async {
|
||||
// final snapshot = await service.scanInstallation(rootDirectory.path);
|
||||
// final paths = snapshot.files.map((file) => file.path).toList()..sort();
|
||||
test(
|
||||
'finds only unmanaged Data files and legacy client binaries',
|
||||
() async {
|
||||
final retainedPaths = [
|
||||
'MoonWell.exe',
|
||||
'Data/common.MPQ',
|
||||
'Data/ruRU/realmlist.wtf',
|
||||
'Data/Addons/required.MPQ',
|
||||
'required.DLL',
|
||||
'WoW/required.dll',
|
||||
'WoW/Data/common.MPQ',
|
||||
];
|
||||
final staleFiles = [
|
||||
'Data/old.MPQ',
|
||||
'Data/ruRU/old.MPQ',
|
||||
'Data/Addons/extra.MPQ',
|
||||
'Data/Cache/old.bin',
|
||||
'Data/empty/old.MPQ',
|
||||
'Data/common.MPQ.moonwell.part',
|
||||
'Data/broken.MPQ.moonwell.part.failed',
|
||||
'WoW.exe',
|
||||
'old.DLL',
|
||||
'WoW/WoW.exe',
|
||||
'WoW/old.dll',
|
||||
'WoW/Data/old.MPQ',
|
||||
'Extentions/bin/extension.dll',
|
||||
'WoW/Extentions/old.txt',
|
||||
];
|
||||
final untouchedPaths = [
|
||||
'Cache/cache.bin',
|
||||
'Logs/launcher.log',
|
||||
'WTF/Config.wtf',
|
||||
'Errors/error.dll',
|
||||
'Screenshots/shot.dll',
|
||||
'benilla-config/settings.dll',
|
||||
'.moonwell_launcher/local.dll',
|
||||
'Interface/AddOns/custom/addon.dll',
|
||||
'notes.txt',
|
||||
'Other.exe',
|
||||
'realmlist.wtf',
|
||||
];
|
||||
for (final path in [
|
||||
...retainedPaths,
|
||||
...staleFiles,
|
||||
...untouchedPaths,
|
||||
]) {
|
||||
final file = File(
|
||||
service.resolveClientPath(rootDirectory.path, path),
|
||||
);
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsString(path);
|
||||
}
|
||||
await Directory(
|
||||
service.resolveClientPath(rootDirectory.path, 'Data/unused/nested'),
|
||||
).create(recursive: true);
|
||||
await Directory(
|
||||
service.resolveClientPath(rootDirectory.path, 'Extentions/empty'),
|
||||
).create(recursive: true);
|
||||
final manifest = await _manifestForPaths(
|
||||
service,
|
||||
rootDirectory,
|
||||
retainedPaths,
|
||||
);
|
||||
|
||||
// expect(paths, ['Data/common.MPQ', 'Wow.exe']);
|
||||
// expect(snapshot.buildHash, computeBuildHash(snapshot.files));
|
||||
// });
|
||||
final snapshot = await service.scanInstallation(
|
||||
rootDirectory.path,
|
||||
manifest: manifest,
|
||||
);
|
||||
|
||||
// test(
|
||||
// 'scanInstallation creates launcher hash cache outside verification set',
|
||||
// () async {
|
||||
// await service.scanInstallation(rootDirectory.path);
|
||||
expect(
|
||||
snapshot.files.map((file) => file.path),
|
||||
unorderedEquals(retainedPaths),
|
||||
);
|
||||
expect(snapshot.buildHash, manifest.buildHash);
|
||||
expect(
|
||||
snapshot.stalePaths,
|
||||
unorderedEquals([
|
||||
...staleFiles,
|
||||
'Data/Cache',
|
||||
'Data/empty',
|
||||
'Data/unused/nested',
|
||||
'Data/unused',
|
||||
'Extentions/bin',
|
||||
'Extentions/empty',
|
||||
'Extentions',
|
||||
'WoW/Extentions',
|
||||
]),
|
||||
);
|
||||
for (final path in snapshot.stalePaths) {
|
||||
await service.deleteRelativeFile(rootDirectory.path, path);
|
||||
}
|
||||
for (final path in [...retainedPaths, ...untouchedPaths]) {
|
||||
expect(
|
||||
await File(
|
||||
service.resolveClientPath(rootDirectory.path, path),
|
||||
).exists(),
|
||||
isTrue,
|
||||
reason: path,
|
||||
);
|
||||
}
|
||||
for (final path in snapshot.stalePaths) {
|
||||
expect(
|
||||
await FileSystemEntity.type(
|
||||
service.resolveClientPath(rootDirectory.path, path),
|
||||
followLinks: false,
|
||||
),
|
||||
FileSystemEntityType.notFound,
|
||||
reason: path,
|
||||
);
|
||||
}
|
||||
|
||||
// final cacheFile = File(service.getHashCachePath(rootDirectory.path));
|
||||
// expect(await cacheFile.exists(), isTrue);
|
||||
final verified = await service.scanInstallation(
|
||||
rootDirectory.path,
|
||||
manifest: manifest,
|
||||
);
|
||||
expect(verified.stalePaths, isEmpty);
|
||||
expect(verified.buildHash, manifest.buildHash);
|
||||
expect(
|
||||
await File(service.getHashCachePath(rootDirectory.path)).exists(),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// final secondSnapshot = await service.scanInstallation(
|
||||
// rootDirectory.path,
|
||||
// );
|
||||
// final paths = secondSnapshot.files.map((file) => file.path).toList()
|
||||
// ..sort();
|
||||
test('removes Data links without touching their targets', () async {
|
||||
final outside = await Directory.systemTemp.createTemp(
|
||||
'moonwell_outside_',
|
||||
);
|
||||
addTearDown(() => outside.delete(recursive: true));
|
||||
final outsideFile = File(
|
||||
'${outside.path}${Platform.pathSeparator}keep.dll',
|
||||
);
|
||||
await outsideFile.writeAsString('keep');
|
||||
final link = Link(
|
||||
service.resolveClientPath(rootDirectory.path, 'Data/external'),
|
||||
);
|
||||
await link.create(outside.path);
|
||||
final manifest = await _manifestForPaths(service, rootDirectory, [
|
||||
'Data/common.MPQ',
|
||||
]);
|
||||
|
||||
// expect(paths, ['Data/common.MPQ', 'Wow.exe']);
|
||||
// expect(
|
||||
// await Directory(
|
||||
// '${rootDirectory.path}${Platform.pathSeparator}${Config.launcherMetadataDirectoryName}',
|
||||
// ).exists(),
|
||||
// isTrue,
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
final snapshot = await service.scanInstallation(
|
||||
rootDirectory.path,
|
||||
manifest: manifest,
|
||||
);
|
||||
expect(snapshot.stalePaths, ['Data/external']);
|
||||
await service.deleteRelativeFile(rootDirectory.path, 'Data/external');
|
||||
expect(await link.exists(), isFalse);
|
||||
expect(await outsideFile.readAsString(), 'keep');
|
||||
});
|
||||
|
||||
test('retains a directory link needed by manifest files', () async {
|
||||
final target = Directory(
|
||||
service.resolveClientPath(rootDirectory.path, 'shared-data'),
|
||||
);
|
||||
await target.create();
|
||||
await File(
|
||||
'${target.path}${Platform.pathSeparator}required.MPQ',
|
||||
).writeAsString('required');
|
||||
final link = Link(
|
||||
service.resolveClientPath(rootDirectory.path, 'Data/linked'),
|
||||
);
|
||||
await link.create(target.path);
|
||||
final manifest = await _manifestForPaths(service, rootDirectory, [
|
||||
'Data/common.MPQ',
|
||||
'Data/linked/required.MPQ',
|
||||
]);
|
||||
final snapshot = await service.scanInstallation(
|
||||
rootDirectory.path,
|
||||
manifest: manifest,
|
||||
);
|
||||
expect(snapshot.stalePaths, isEmpty);
|
||||
expect(snapshot.buildHash, manifest.buildHash);
|
||||
expect(await link.exists(), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'rejects deleting through a directory link outside installation',
|
||||
() async {
|
||||
final outside = await Directory.systemTemp.createTemp(
|
||||
'moonwell_outside_',
|
||||
);
|
||||
addTearDown(() => outside.delete(recursive: true));
|
||||
final outsideFile = File(
|
||||
'${outside.path}${Platform.pathSeparator}keep.dll',
|
||||
);
|
||||
await outsideFile.writeAsString('keep');
|
||||
final link = Link(
|
||||
service.resolveClientPath(rootDirectory.path, 'Data/external'),
|
||||
);
|
||||
await link.create(outside.path);
|
||||
await expectLater(
|
||||
service.deleteRelativeFile(
|
||||
rootDirectory.path,
|
||||
'Data/external/keep.dll',
|
||||
),
|
||||
throwsA(isA<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);
|
||||
@@ -180,6 +430,7 @@ void main() {
|
||||
'WOW_PASS': 'A1B2C3D4E5F6G7H8',
|
||||
'WOW_HOST': '127.0.0.1:3724',
|
||||
'WOW_DATA': '${rootDirectory.path}${Platform.pathSeparator}Data',
|
||||
'WOW_LOCALE': 'auto',
|
||||
'BENILLA_HOME':
|
||||
'${rootDirectory.path}${Platform.pathSeparator}benilla-config',
|
||||
});
|
||||
@@ -238,6 +489,46 @@ void main() {
|
||||
expect(recordingService.environment?['WOW_DATA'], nestedData.path);
|
||||
});
|
||||
|
||||
test('verifies and retains Russian archives before launching', () async {
|
||||
final paths = [
|
||||
'MoonWell.exe',
|
||||
'Data/common.MPQ',
|
||||
'Data/ruRU/patch-1.MPQ',
|
||||
'Data/ruRU/patch-2.MPQ',
|
||||
'Data/ruRU/patch-3.MPQ',
|
||||
];
|
||||
for (final path in paths.skip(2)) {
|
||||
final file = File(service.resolveClientPath(rootDirectory.path, path));
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsString('localized archive: $path');
|
||||
}
|
||||
final manifest = await _manifestForPaths(service, rootDirectory, paths);
|
||||
final snapshot = await service.scanInstallation(
|
||||
rootDirectory.path,
|
||||
manifest: manifest,
|
||||
);
|
||||
expect(snapshot.buildHash, manifest.buildHash);
|
||||
expect(snapshot.stalePaths, isEmpty);
|
||||
final recordingService = _RecordingGameInstallationService();
|
||||
await recordingService.launchGame(
|
||||
rootDirectory.path,
|
||||
authorization: _validTicket(),
|
||||
);
|
||||
expect(recordingService.environment?['WOW_LOCALE'], 'auto');
|
||||
expect(
|
||||
recordingService.environment?['WOW_DATA'],
|
||||
'${rootDirectory.path}${Platform.pathSeparator}Data',
|
||||
);
|
||||
for (final path in paths.skip(2)) {
|
||||
expect(
|
||||
await File(
|
||||
service.resolveClientPath(rootDirectory.path, path),
|
||||
).readAsString(),
|
||||
'localized archive: $path',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('configured auth address overrides the install realmlist', () async {
|
||||
final configured = _RecordingGameInstallationService(
|
||||
authAddress: 'login.example:3725',
|
||||
@@ -335,6 +626,25 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
Future<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