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