|
|
|
@@ -1,5 +1,7 @@
|
|
|
|
|
import 'dart:async';
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:io';
|
|
|
|
|
import 'dart:isolate';
|
|
|
|
|
|
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
|
|
|
import 'package:injectable/injectable.dart';
|
|
|
|
@@ -32,56 +34,118 @@ class GameInstallationService {
|
|
|
|
|
return ClientInstallationSnapshot.fromFiles(const []);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final pendingFiles = <_PendingFile>[];
|
|
|
|
|
await _collectFiles(
|
|
|
|
|
rootDirectory,
|
|
|
|
|
rootDirectory.path,
|
|
|
|
|
pendingFiles,
|
|
|
|
|
isCancelled: isCancelled,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
pendingFiles.sort(
|
|
|
|
|
(left, right) => left.relativePath.compareTo(right.relativePath),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final totalBytes = pendingFiles.fold<int>(
|
|
|
|
|
0,
|
|
|
|
|
(sum, pendingFile) => sum + pendingFile.size,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final receivePort = ReceivePort();
|
|
|
|
|
final errorPort = ReceivePort();
|
|
|
|
|
final exitPort = ReceivePort();
|
|
|
|
|
final files = <LocalClientFile>[];
|
|
|
|
|
var processedBytes = 0;
|
|
|
|
|
var processedFiles = 0;
|
|
|
|
|
final completer = Completer<ClientInstallationSnapshot>();
|
|
|
|
|
final hashCachePath = getHashCachePath(installationDir);
|
|
|
|
|
final cachedHashes = await loadHashCache(installationDir);
|
|
|
|
|
Isolate? isolate;
|
|
|
|
|
StreamSubscription<dynamic>? receiveSubscription;
|
|
|
|
|
StreamSubscription<dynamic>? errorSubscription;
|
|
|
|
|
StreamSubscription<dynamic>? exitSubscription;
|
|
|
|
|
Timer? cancellationTimer;
|
|
|
|
|
|
|
|
|
|
for (final pendingFile in pendingFiles) {
|
|
|
|
|
await _throwIfCancelled(isCancelled);
|
|
|
|
|
|
|
|
|
|
final sha256 = await computeSha256(pendingFile.file.path);
|
|
|
|
|
processedBytes += pendingFile.size;
|
|
|
|
|
processedFiles += 1;
|
|
|
|
|
|
|
|
|
|
files.add(
|
|
|
|
|
LocalClientFile(
|
|
|
|
|
path: pendingFile.relativePath,
|
|
|
|
|
size: pendingFile.size,
|
|
|
|
|
sha256: sha256,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
onProgress?.call(
|
|
|
|
|
DownloadProgress(
|
|
|
|
|
speed: 0,
|
|
|
|
|
downloaded: processedBytes,
|
|
|
|
|
total: totalBytes,
|
|
|
|
|
eta: Duration.zero,
|
|
|
|
|
),
|
|
|
|
|
pendingFile.relativePath,
|
|
|
|
|
processedFiles,
|
|
|
|
|
pendingFiles.length,
|
|
|
|
|
);
|
|
|
|
|
Future<void> cleanup({bool killIsolate = true}) async {
|
|
|
|
|
cancellationTimer?.cancel();
|
|
|
|
|
await receiveSubscription?.cancel();
|
|
|
|
|
await errorSubscription?.cancel();
|
|
|
|
|
await exitSubscription?.cancel();
|
|
|
|
|
receivePort.close();
|
|
|
|
|
errorPort.close();
|
|
|
|
|
exitPort.close();
|
|
|
|
|
if (killIsolate) {
|
|
|
|
|
isolate?.kill(priority: Isolate.immediate);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ClientInstallationSnapshot.fromFiles(files);
|
|
|
|
|
receiveSubscription = receivePort.listen((message) async {
|
|
|
|
|
if (completer.isCompleted || message is! Map) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final type = message['type'];
|
|
|
|
|
if (type == 'progress') {
|
|
|
|
|
final file = LocalClientFile(
|
|
|
|
|
path: message['path'] as String,
|
|
|
|
|
size: (message['size'] as num).toInt(),
|
|
|
|
|
sha256: message['sha256'] as String,
|
|
|
|
|
);
|
|
|
|
|
files.add(file);
|
|
|
|
|
|
|
|
|
|
onProgress?.call(
|
|
|
|
|
DownloadProgress(
|
|
|
|
|
speed: 0,
|
|
|
|
|
downloaded: (message['processedBytes'] as num).toInt(),
|
|
|
|
|
total: (message['totalBytes'] as num).toInt(),
|
|
|
|
|
eta: Duration.zero,
|
|
|
|
|
),
|
|
|
|
|
file.path,
|
|
|
|
|
(message['processedFiles'] as num).toInt(),
|
|
|
|
|
(message['totalFiles'] as num).toInt(),
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (type == 'done') {
|
|
|
|
|
final snapshot = ClientInstallationSnapshot.fromFiles(files);
|
|
|
|
|
completer.complete(snapshot);
|
|
|
|
|
await cleanup(killIsolate: false);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
errorSubscription = errorPort.listen((message) async {
|
|
|
|
|
if (completer.isCompleted) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
completer.completeError(
|
|
|
|
|
LauncherSyncException('Failed to scan installation files.'),
|
|
|
|
|
);
|
|
|
|
|
await cleanup(killIsolate: false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
exitSubscription = exitPort.listen((_) async {
|
|
|
|
|
if (completer.isCompleted) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
completer.completeError(
|
|
|
|
|
LauncherSyncException('Hash scan worker exited unexpectedly.'),
|
|
|
|
|
);
|
|
|
|
|
await cleanup(killIsolate: false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
isolate = await Isolate.spawn<Map<String, Object?>>(
|
|
|
|
|
_scanInstallationIsolateMain,
|
|
|
|
|
<String, Object?>{
|
|
|
|
|
'sendPort': receivePort.sendPort,
|
|
|
|
|
'installationDir': installationDir,
|
|
|
|
|
'hashCachePath': hashCachePath,
|
|
|
|
|
'cachedHashes': cachedHashes,
|
|
|
|
|
'ignoredDirectories': Config.ignoredVerificationDirectories.toList(),
|
|
|
|
|
},
|
|
|
|
|
onError: errorPort.sendPort,
|
|
|
|
|
onExit: exitPort.sendPort,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (isCancelled != null) {
|
|
|
|
|
cancellationTimer = Timer.periodic(const Duration(milliseconds: 200), (
|
|
|
|
|
_,
|
|
|
|
|
) async {
|
|
|
|
|
if (completer.isCompleted) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (await isCancelled()) {
|
|
|
|
|
completer.completeError(const CancelledException());
|
|
|
|
|
await cleanup();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return completer.future;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<String> computeSha256(String filePath) async {
|
|
|
|
@@ -109,6 +173,10 @@ class GameInstallationService {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> ensureParentDirectoryExists(String filePath) {
|
|
|
|
|
return File(filePath).parent.create(recursive: true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> replaceFile({
|
|
|
|
|
required String temporaryPath,
|
|
|
|
|
required String destinationPath,
|
|
|
|
@@ -163,6 +231,17 @@ class GameInstallationService {
|
|
|
|
|
return File(getExecutablePath(installationDir)).exists();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String getLauncherMetadataDirectoryPath(String installationDir) {
|
|
|
|
|
return p.join(installationDir, Config.launcherMetadataDirectoryName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String getHashCachePath(String installationDir) {
|
|
|
|
|
return p.join(
|
|
|
|
|
getLauncherMetadataDirectoryPath(installationDir),
|
|
|
|
|
Config.hashCacheFileName,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String getExecutablePath(String installationDir) {
|
|
|
|
|
return p.join(installationDir, Config.gameExecutableName);
|
|
|
|
|
}
|
|
|
|
@@ -183,74 +262,195 @@ class GameInstallationService {
|
|
|
|
|
return p.joinAll([installationDir, ...pathSegments]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _collectFiles(
|
|
|
|
|
Directory directory,
|
|
|
|
|
String rootPath,
|
|
|
|
|
List<_PendingFile> pendingFiles, {
|
|
|
|
|
FutureOr<bool> Function()? isCancelled,
|
|
|
|
|
}) async {
|
|
|
|
|
await for (final entity in directory.list(followLinks: false)) {
|
|
|
|
|
await _throwIfCancelled(isCancelled);
|
|
|
|
|
Future<Map<String, Object?>> loadHashCache(String installationDir) async {
|
|
|
|
|
final cacheFile = File(getHashCachePath(installationDir));
|
|
|
|
|
if (!await cacheFile.exists()) {
|
|
|
|
|
return const <String, Object?>{};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final relativePath = normalizeClientPath(
|
|
|
|
|
p.relative(entity.path, from: rootPath),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (relativePath.isEmpty || relativePath == '.') {
|
|
|
|
|
continue;
|
|
|
|
|
try {
|
|
|
|
|
final rawJson = await cacheFile.readAsString();
|
|
|
|
|
final decoded = jsonDecode(rawJson);
|
|
|
|
|
if (decoded is! Map) {
|
|
|
|
|
return const <String, Object?>{};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (_isExcludedPath(relativePath)) {
|
|
|
|
|
continue;
|
|
|
|
|
final entries = decoded['entries'];
|
|
|
|
|
if (entries is! Map) {
|
|
|
|
|
return const <String, Object?>{};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entity is Directory) {
|
|
|
|
|
await _collectFiles(
|
|
|
|
|
entity,
|
|
|
|
|
rootPath,
|
|
|
|
|
pendingFiles,
|
|
|
|
|
isCancelled: isCancelled,
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
return entries.map((key, value) => MapEntry(key.toString(), value));
|
|
|
|
|
} catch (_) {
|
|
|
|
|
return const <String, Object?>{};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entity is! File) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
Future<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
|
|
|
|
|
final sendPort = message['sendPort'] as SendPort;
|
|
|
|
|
final installationDir = message['installationDir'] as String;
|
|
|
|
|
final hashCachePath = message['hashCachePath'] as String;
|
|
|
|
|
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 stat = await entity.stat();
|
|
|
|
|
pendingFiles.add(
|
|
|
|
|
_PendingFile(file: entity, relativePath: relativePath, size: stat.size),
|
|
|
|
|
);
|
|
|
|
|
final rootDirectory = Directory(installationDir);
|
|
|
|
|
if (!await rootDirectory.exists()) {
|
|
|
|
|
sendPort.send(<String, Object?>{'type': 'done'});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final pendingFiles = <_SerializablePendingFile>[];
|
|
|
|
|
await _collectFilesInIsolate(
|
|
|
|
|
rootDirectory,
|
|
|
|
|
rootDirectory.path,
|
|
|
|
|
pendingFiles,
|
|
|
|
|
ignoredDirectories,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
pendingFiles.sort(
|
|
|
|
|
(left, right) => left.relativePath.compareTo(right.relativePath),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final totalBytes = pendingFiles.fold<int>(
|
|
|
|
|
0,
|
|
|
|
|
(sum, pendingFile) => sum + pendingFile.size,
|
|
|
|
|
);
|
|
|
|
|
final updatedCacheEntries = <String, Map<String, Object?>>{};
|
|
|
|
|
|
|
|
|
|
var processedBytes = 0;
|
|
|
|
|
var processedFiles = 0;
|
|
|
|
|
|
|
|
|
|
for (final pendingFile in pendingFiles) {
|
|
|
|
|
final digest = await _resolveFileHash(pendingFile, cachedHashes);
|
|
|
|
|
processedBytes += pendingFile.size;
|
|
|
|
|
processedFiles += 1;
|
|
|
|
|
updatedCacheEntries[pendingFile.relativePath] = <String, Object?>{
|
|
|
|
|
'size': pendingFile.size,
|
|
|
|
|
'modifiedMs': pendingFile.modifiedMs,
|
|
|
|
|
'sha256': digest,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
sendPort.send(<String, Object?>{
|
|
|
|
|
'type': 'progress',
|
|
|
|
|
'path': pendingFile.relativePath,
|
|
|
|
|
'size': pendingFile.size,
|
|
|
|
|
'sha256': digest,
|
|
|
|
|
'processedBytes': processedBytes,
|
|
|
|
|
'totalBytes': totalBytes,
|
|
|
|
|
'processedFiles': processedFiles,
|
|
|
|
|
'totalFiles': pendingFiles.length,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await _writeHashCache(hashCachePath, updatedCacheEntries);
|
|
|
|
|
sendPort.send(<String, Object?>{'type': 'done'});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<String> _resolveFileHash(
|
|
|
|
|
_SerializablePendingFile pendingFile,
|
|
|
|
|
Map<String, Object?> cachedHashes,
|
|
|
|
|
) async {
|
|
|
|
|
final cachedEntry = cachedHashes[pendingFile.relativePath];
|
|
|
|
|
if (cachedEntry is Map) {
|
|
|
|
|
final cachedSize = (cachedEntry['size'] as num?)?.toInt();
|
|
|
|
|
final cachedModifiedMs = (cachedEntry['modifiedMs'] as num?)?.toInt();
|
|
|
|
|
final cachedSha256 = cachedEntry['sha256'] as String?;
|
|
|
|
|
|
|
|
|
|
if (cachedSize == pendingFile.size &&
|
|
|
|
|
cachedModifiedMs == pendingFile.modifiedMs &&
|
|
|
|
|
cachedSha256 != null &&
|
|
|
|
|
cachedSha256.isNotEmpty) {
|
|
|
|
|
return cachedSha256;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool _isExcludedPath(String relativePath) {
|
|
|
|
|
final segments = p.posix.split(normalizeClientPath(relativePath));
|
|
|
|
|
if (segments.isEmpty) {
|
|
|
|
|
return false;
|
|
|
|
|
final digest = await sha256.bind(File(pendingFile.path).openRead()).first;
|
|
|
|
|
return digest.toString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _collectFilesInIsolate(
|
|
|
|
|
Directory directory,
|
|
|
|
|
String rootPath,
|
|
|
|
|
List<_SerializablePendingFile> pendingFiles,
|
|
|
|
|
Set<String> ignoredDirectories,
|
|
|
|
|
) async {
|
|
|
|
|
await for (final entity in directory.list(followLinks: false)) {
|
|
|
|
|
final relativePath = normalizeClientPath(
|
|
|
|
|
p.relative(entity.path, from: rootPath),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (relativePath.isEmpty || relativePath == '.') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Config.ignoredVerificationDirectories.contains(
|
|
|
|
|
segments.first.toLowerCase(),
|
|
|
|
|
final segments = p.posix.split(relativePath);
|
|
|
|
|
if (segments.isNotEmpty &&
|
|
|
|
|
ignoredDirectories.contains(segments.first.toLowerCase())) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entity is Directory) {
|
|
|
|
|
await _collectFilesInIsolate(
|
|
|
|
|
entity,
|
|
|
|
|
rootPath,
|
|
|
|
|
pendingFiles,
|
|
|
|
|
ignoredDirectories,
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entity is! File) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final stat = await entity.stat();
|
|
|
|
|
pendingFiles.add(
|
|
|
|
|
_SerializablePendingFile(
|
|
|
|
|
path: entity.path,
|
|
|
|
|
relativePath: relativePath,
|
|
|
|
|
size: stat.size,
|
|
|
|
|
modifiedMs: stat.modified.millisecondsSinceEpoch,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _throwIfCancelled(FutureOr<bool> Function()? isCancelled) async {
|
|
|
|
|
if (isCancelled != null && await isCancelled()) {
|
|
|
|
|
throw const CancelledException();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final class _PendingFile {
|
|
|
|
|
final File file;
|
|
|
|
|
Future<void> _writeHashCache(
|
|
|
|
|
String hashCachePath,
|
|
|
|
|
Map<String, Map<String, Object?>> entries,
|
|
|
|
|
) async {
|
|
|
|
|
final cacheFile = File(hashCachePath);
|
|
|
|
|
await cacheFile.parent.create(recursive: true);
|
|
|
|
|
|
|
|
|
|
final tempFile = File('$hashCachePath.tmp');
|
|
|
|
|
final payload = jsonEncode(<String, Object?>{
|
|
|
|
|
'version': 1,
|
|
|
|
|
'entries': entries,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await tempFile.writeAsString(payload, flush: true);
|
|
|
|
|
if (await cacheFile.exists()) {
|
|
|
|
|
await cacheFile.delete();
|
|
|
|
|
}
|
|
|
|
|
await tempFile.rename(hashCachePath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final class _SerializablePendingFile {
|
|
|
|
|
final String path;
|
|
|
|
|
final String relativePath;
|
|
|
|
|
final int size;
|
|
|
|
|
final int modifiedMs;
|
|
|
|
|
|
|
|
|
|
const _PendingFile({
|
|
|
|
|
required this.file,
|
|
|
|
|
const _SerializablePendingFile({
|
|
|
|
|
required this.path,
|
|
|
|
|
required this.relativePath,
|
|
|
|
|
required this.size,
|
|
|
|
|
required this.modifiedMs,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|