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

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
@@ -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,
);
}