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:
2026-10-10 16:50:35 +03:00
parent d3d19bfaeb
commit f834c5fa86
11 changed files with 862 additions and 259 deletions
@@ -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();
}
}
@@ -285,6 +304,8 @@ class GameInstallationService {
'WOW_PASS': authorization.ticket,
'WOW_HOST': authAddress,
'WOW_DATA': dataPath,
// Let the client select Data/ruRU independently of the launcher's parent environment.
'WOW_LOCALE': 'auto',
'BENILLA_HOME': p.join(rootPath, 'benilla-config'),
},
);
@@ -394,7 +415,9 @@ class GameInstallationService {
if (pathSegments.isEmpty ||
pathSegments.any((segment) => segment == '..') ||
p.posix.isAbsolute(normalizedPath)) {
pathSegments.any((segment) => segment.contains(':')) ||
p.posix.isAbsolute(normalizedPath) ||
p.windows.isAbsolute(normalizedPath)) {
throw LauncherSyncException('Invalid client file path: $relativePath');
}
@@ -460,28 +483,19 @@ Future<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 +533,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 +558,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 +594,88 @@ 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> collectStaleDirectory(Directory directory) async {
await for (final entity in directory.list(followLinks: false)) {
final path = relativePath(entity);
if (entity is Directory) {
await collectStaleDirectory(entity);
if (!manifestDirectories.contains(pathKey(path))) {
stalePaths.add(path);
}
} else if (!manifestPaths.contains(pathKey(path)) &&
!(entity is Link && manifestDirectories.contains(pathKey(path)))) {
stalePaths.add(path);
}
}
}
Future<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 collectStaleDirectory(entity);
} else if (name == 'extentions') {
await collectStaleDirectory(entity);
if (!manifestDirectories.contains(pathKey(relativePath(entity)))) {
stalePaths.add(relativePath(entity));
}
} else if (!nested && name == 'wow') {
await collectClientRoot(entity, nested: true);
}
} else if ((name == 'wow.exe' ||
name.endsWith('.dll') ||
(entity is Link && name == 'extentions')) &&
!manifestPaths.contains(pathKey(relativePath(entity))) &&
!manifestDirectories.contains(pathKey(relativePath(entity)))) {
stalePaths.add(relativePath(entity));
}
}
}
await collectClientRoot(rootDirectory);
return stalePaths;
}
Future<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,
);
}