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

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
@@ -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,