загрзука клиента и хэширование
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,10 +61,17 @@ class LauncherApiClient {
|
||||
required void Function(int chunkBytes) onChunkReceived,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.getUri<ResponseBody>(
|
||||
final downloadLinkResponse = await _dio.getUri(
|
||||
_resolveUri('api/launcher/download/${Uri.encodeComponent(file.path)}'),
|
||||
options: _authorizedOptions(
|
||||
accessToken,
|
||||
options: _authorizedOptions(accessToken),
|
||||
);
|
||||
final downloadUri = _extractDownloadUri(
|
||||
_coerceMap(downloadLinkResponse.data),
|
||||
);
|
||||
|
||||
final response = await _dio.getUri<ResponseBody>(
|
||||
downloadUri,
|
||||
options: Options(
|
||||
responseType: ResponseType.stream,
|
||||
headers: const {HttpHeaders.acceptHeader: 'application/octet-stream'},
|
||||
),
|
||||
@@ -173,4 +180,22 @@ class LauncherApiClient {
|
||||
|
||||
return error.message ?? 'Launcher API request failed.';
|
||||
}
|
||||
|
||||
Uri _extractDownloadUri(Map<String, dynamic> payload) {
|
||||
final rawUrl = payload['url'];
|
||||
if (rawUrl is! String || rawUrl.trim().isEmpty) {
|
||||
throw const LauncherApiException(
|
||||
'Launcher API did not return a valid download URL.',
|
||||
);
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(rawUrl);
|
||||
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
|
||||
throw const LauncherApiException(
|
||||
'Launcher API returned an invalid presigned download URL.',
|
||||
);
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user