загрзука клиента и хэширование

This commit is contained in:
2026-03-22 02:00:47 +04:00
parent 73d8798bb9
commit f0463f8cff
9 changed files with 1239 additions and 97 deletions
+3
View File
@@ -4,11 +4,14 @@ final class Config {
);
static const gameExecutableName = 'Wow.exe';
static const cacheDirectoryName = 'Cache';
static const launcherMetadataDirectoryName = '.moonwell_launcher';
static const hashCacheFileName = 'hash_cache.json';
static const ignoredVerificationDirectories = <String>{
'cache',
'errors',
'logs',
'screenshots',
'wtf',
'.moonwell_launcher',
};
}
@@ -349,6 +349,7 @@ class ClientSyncUseCase
);
final temporaryPath = '$destinationPath.moonwell.part';
await _installationService.ensureParentDirectoryExists(temporaryPath);
await _installationService.deleteFileIfExists(temporaryPath);
await _launcherApiClient.downloadFile(
@@ -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;
}
}