базовая логика скачивания обновлений
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:injectable/injectable.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';
|
||||
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||
|
||||
final class ClientSyncRequest {
|
||||
final String installationDir;
|
||||
final String accessToken;
|
||||
final ClientManifest? manifest;
|
||||
|
||||
const ClientSyncRequest({
|
||||
required this.installationDir,
|
||||
required this.accessToken,
|
||||
this.manifest,
|
||||
});
|
||||
}
|
||||
|
||||
final class ClientSyncUseCaseInput {
|
||||
final ClientSyncRequest request;
|
||||
final FutureOr<bool> Function()? isCancelled;
|
||||
|
||||
const ClientSyncUseCaseInput({required this.request, this.isCancelled});
|
||||
}
|
||||
|
||||
@lazySingleton
|
||||
class ClientSyncUseCase
|
||||
implements StreamUseCase<ClientSyncUseCaseInput, ClientSyncStatus> {
|
||||
ClientSyncUseCase({
|
||||
required LauncherApiClient launcherApiClient,
|
||||
required GameInstallationService installationService,
|
||||
}) : _launcherApiClient = launcherApiClient,
|
||||
_installationService = installationService;
|
||||
|
||||
final LauncherApiClient _launcherApiClient;
|
||||
final GameInstallationService _installationService;
|
||||
|
||||
@override
|
||||
Stream<ClientSyncStatus> call(ClientSyncUseCaseInput input) {
|
||||
final controller = StreamController<ClientSyncStatus>();
|
||||
unawaited(_run(input, controller));
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Future<void> _run(
|
||||
ClientSyncUseCaseInput input,
|
||||
StreamController<ClientSyncStatus> controller,
|
||||
) async {
|
||||
try {
|
||||
await _throwIfCancelled(input.isCancelled);
|
||||
|
||||
_emit(
|
||||
controller,
|
||||
ClientSyncStatus(
|
||||
stage: ClientSyncStage.fetchingManifest,
|
||||
progress: const DownloadProgress.initial(),
|
||||
message: 'Loading client manifest...',
|
||||
currentPath: null,
|
||||
localBuildHash: null,
|
||||
remoteBuildHash: null,
|
||||
processedFiles: 0,
|
||||
totalFiles: 0,
|
||||
),
|
||||
);
|
||||
|
||||
final manifest =
|
||||
input.request.manifest ??
|
||||
await _launcherApiClient.fetchManifest(input.request.accessToken);
|
||||
final remoteBuildHash = manifest.buildHash;
|
||||
|
||||
final localSnapshot = await _installationService.scanInstallation(
|
||||
input.request.installationDir,
|
||||
isCancelled: input.isCancelled,
|
||||
onProgress: (progress, currentPath, processedFiles, totalFiles) {
|
||||
_emit(
|
||||
controller,
|
||||
ClientSyncStatus(
|
||||
stage: ClientSyncStage.scanningLocalFiles,
|
||||
progress: progress,
|
||||
message: 'Hashing local files...',
|
||||
currentPath: currentPath,
|
||||
localBuildHash: null,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
processedFiles: processedFiles,
|
||||
totalFiles: totalFiles,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await _throwIfCancelled(input.isCancelled);
|
||||
|
||||
final filesToDownload = _resolveFilesToDownload(manifest, localSnapshot);
|
||||
final staleFiles = _resolveStaleFiles(manifest, localSnapshot);
|
||||
|
||||
_emit(
|
||||
controller,
|
||||
ClientSyncStatus(
|
||||
stage: ClientSyncStage.comparingFiles,
|
||||
progress: DownloadProgress(
|
||||
speed: 0,
|
||||
downloaded: localSnapshot.files.length,
|
||||
total: manifest.files.length,
|
||||
eta: Duration.zero,
|
||||
),
|
||||
message: _buildComparisonMessage(
|
||||
localSnapshot: localSnapshot,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
filesToDownload: filesToDownload.length,
|
||||
staleFiles: staleFiles.length,
|
||||
),
|
||||
currentPath: null,
|
||||
localBuildHash: localSnapshot.buildHash,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
processedFiles: localSnapshot.files.length,
|
||||
totalFiles: manifest.files.length,
|
||||
),
|
||||
);
|
||||
|
||||
if (localSnapshot.buildHash == remoteBuildHash &&
|
||||
filesToDownload.isEmpty &&
|
||||
staleFiles.isEmpty) {
|
||||
_emit(
|
||||
controller,
|
||||
ClientSyncStatus(
|
||||
stage: ClientSyncStage.completed,
|
||||
progress: _fullProgress(manifest),
|
||||
message: 'Client is up to date.',
|
||||
currentPath: null,
|
||||
localBuildHash: localSnapshot.buildHash,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
processedFiles: manifest.files.length,
|
||||
totalFiles: manifest.files.length,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (staleFiles.isNotEmpty) {
|
||||
await _removeStaleFiles(
|
||||
controller: controller,
|
||||
installationDir: input.request.installationDir,
|
||||
staleFiles: staleFiles,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
isCancelled: input.isCancelled,
|
||||
);
|
||||
}
|
||||
|
||||
if (filesToDownload.isNotEmpty) {
|
||||
await _downloadChangedFiles(
|
||||
controller: controller,
|
||||
manifest: manifest,
|
||||
accessToken: input.request.accessToken,
|
||||
installationDir: input.request.installationDir,
|
||||
filesToDownload: filesToDownload,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
isCancelled: input.isCancelled,
|
||||
);
|
||||
}
|
||||
|
||||
final verifiedSnapshot = await _installationService.scanInstallation(
|
||||
input.request.installationDir,
|
||||
isCancelled: input.isCancelled,
|
||||
onProgress: (progress, currentPath, processedFiles, totalFiles) {
|
||||
_emit(
|
||||
controller,
|
||||
ClientSyncStatus(
|
||||
stage: ClientSyncStage.verifyingInstallation,
|
||||
progress: progress,
|
||||
message: 'Verifying updated client...',
|
||||
currentPath: currentPath,
|
||||
localBuildHash: null,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
processedFiles: processedFiles,
|
||||
totalFiles: totalFiles,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await _throwIfCancelled(input.isCancelled);
|
||||
_ensureVerificationPassed(
|
||||
manifest: manifest,
|
||||
snapshot: verifiedSnapshot,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
);
|
||||
|
||||
_emit(
|
||||
controller,
|
||||
ClientSyncStatus(
|
||||
stage: ClientSyncStage.completed,
|
||||
progress: _fullProgress(manifest),
|
||||
message: 'Client is ready to play.',
|
||||
currentPath: null,
|
||||
localBuildHash: verifiedSnapshot.buildHash,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
processedFiles: manifest.files.length,
|
||||
totalFiles: manifest.files.length,
|
||||
),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
if (!controller.isClosed) {
|
||||
controller.addError(error, stackTrace);
|
||||
}
|
||||
} finally {
|
||||
await controller.close();
|
||||
}
|
||||
}
|
||||
|
||||
List<ClientManifestFile> _resolveFilesToDownload(
|
||||
ClientManifest manifest,
|
||||
ClientInstallationSnapshot snapshot,
|
||||
) {
|
||||
final localFilesByPath = snapshot.filesByPath;
|
||||
|
||||
return manifest.files.where((remoteFile) {
|
||||
final localFile = localFilesByPath[remoteFile.path];
|
||||
return localFile == null ||
|
||||
localFile.sha256 != remoteFile.sha256 ||
|
||||
localFile.size != remoteFile.size;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<String> _resolveStaleFiles(
|
||||
ClientManifest manifest,
|
||||
ClientInstallationSnapshot snapshot,
|
||||
) {
|
||||
final remoteFiles = manifest.filesByPath;
|
||||
|
||||
return snapshot.files
|
||||
.where((localFile) => !remoteFiles.containsKey(localFile.path))
|
||||
.map((file) => file.path)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _removeStaleFiles({
|
||||
required StreamController<ClientSyncStatus> controller,
|
||||
required String installationDir,
|
||||
required List<String> staleFiles,
|
||||
required String remoteBuildHash,
|
||||
required FutureOr<bool> Function()? isCancelled,
|
||||
}) async {
|
||||
var removedFiles = 0;
|
||||
|
||||
for (final relativePath in staleFiles) {
|
||||
await _throwIfCancelled(isCancelled);
|
||||
await _installationService.deleteRelativeFile(
|
||||
installationDir,
|
||||
relativePath,
|
||||
);
|
||||
removedFiles += 1;
|
||||
|
||||
_emit(
|
||||
controller,
|
||||
ClientSyncStatus(
|
||||
stage: ClientSyncStage.removingStaleFiles,
|
||||
progress: DownloadProgress(
|
||||
speed: 0,
|
||||
downloaded: removedFiles,
|
||||
total: staleFiles.length,
|
||||
eta: Duration.zero,
|
||||
),
|
||||
message: 'Removing obsolete files...',
|
||||
currentPath: relativePath,
|
||||
localBuildHash: null,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
processedFiles: removedFiles,
|
||||
totalFiles: staleFiles.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadChangedFiles({
|
||||
required StreamController<ClientSyncStatus> controller,
|
||||
required ClientManifest manifest,
|
||||
required String accessToken,
|
||||
required String installationDir,
|
||||
required List<ClientManifestFile> filesToDownload,
|
||||
required String remoteBuildHash,
|
||||
required FutureOr<bool> Function()? isCancelled,
|
||||
}) async {
|
||||
final totalBytes = filesToDownload.fold<int>(
|
||||
0,
|
||||
(sum, file) => sum + file.size,
|
||||
);
|
||||
final stopwatch = Stopwatch()..start();
|
||||
var downloadedBytes = 0;
|
||||
var bytesSinceLastTick = 0;
|
||||
var processedFiles = 0;
|
||||
String? currentPath;
|
||||
|
||||
void emitProgress() {
|
||||
final elapsedMs = stopwatch.elapsedMilliseconds;
|
||||
final speed = elapsedMs == 0
|
||||
? 0
|
||||
: (bytesSinceLastTick * 1000) ~/ elapsedMs;
|
||||
final remainingBytes = totalBytes - downloadedBytes;
|
||||
final eta = speed > 0
|
||||
? Duration(seconds: remainingBytes ~/ speed)
|
||||
: Duration.zero;
|
||||
|
||||
_emit(
|
||||
controller,
|
||||
ClientSyncStatus(
|
||||
stage: ClientSyncStage.downloadingFiles,
|
||||
progress: DownloadProgress(
|
||||
speed: speed,
|
||||
downloaded: downloadedBytes,
|
||||
total: totalBytes,
|
||||
eta: eta,
|
||||
),
|
||||
message: 'Downloading changed files...',
|
||||
currentPath: currentPath,
|
||||
localBuildHash: null,
|
||||
remoteBuildHash: remoteBuildHash,
|
||||
processedFiles: processedFiles,
|
||||
totalFiles: filesToDownload.length,
|
||||
),
|
||||
);
|
||||
|
||||
bytesSinceLastTick = 0;
|
||||
stopwatch
|
||||
..reset()
|
||||
..start();
|
||||
}
|
||||
|
||||
final ticker = Timer.periodic(
|
||||
const Duration(seconds: 1),
|
||||
(_) => emitProgress(),
|
||||
);
|
||||
|
||||
try {
|
||||
for (final file in filesToDownload) {
|
||||
await _throwIfCancelled(isCancelled);
|
||||
|
||||
currentPath = file.path;
|
||||
final destinationPath = _installationService.resolveClientPath(
|
||||
installationDir,
|
||||
file.path,
|
||||
);
|
||||
final temporaryPath = '$destinationPath.moonwell.part';
|
||||
|
||||
await _installationService.deleteFileIfExists(temporaryPath);
|
||||
|
||||
await _launcherApiClient.downloadFile(
|
||||
accessToken: accessToken,
|
||||
file: file,
|
||||
destinationPath: temporaryPath,
|
||||
isCancelled: isCancelled,
|
||||
onChunkReceived: (chunkBytes) {
|
||||
downloadedBytes += chunkBytes;
|
||||
bytesSinceLastTick += chunkBytes;
|
||||
},
|
||||
);
|
||||
|
||||
final isValid = await _installationService.verifySha256(
|
||||
temporaryPath,
|
||||
file.sha256,
|
||||
);
|
||||
if (!isValid) {
|
||||
await _installationService.deleteFileIfExists(temporaryPath);
|
||||
throw LauncherSyncException(
|
||||
'Downloaded file failed verification: ${file.path}',
|
||||
);
|
||||
}
|
||||
|
||||
await _installationService.replaceFile(
|
||||
temporaryPath: temporaryPath,
|
||||
destinationPath: destinationPath,
|
||||
);
|
||||
|
||||
processedFiles += 1;
|
||||
emitProgress();
|
||||
}
|
||||
} finally {
|
||||
ticker.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
void _ensureVerificationPassed({
|
||||
required ClientManifest manifest,
|
||||
required ClientInstallationSnapshot snapshot,
|
||||
required String remoteBuildHash,
|
||||
}) {
|
||||
final localFilesByPath = snapshot.filesByPath;
|
||||
|
||||
final mismatchedFiles = manifest.files.where((remoteFile) {
|
||||
final localFile = localFilesByPath[remoteFile.path];
|
||||
return localFile == null ||
|
||||
localFile.sha256 != remoteFile.sha256 ||
|
||||
localFile.size != remoteFile.size;
|
||||
}).toList();
|
||||
|
||||
final extraFiles = snapshot.files
|
||||
.where((localFile) => !manifest.filesByPath.containsKey(localFile.path))
|
||||
.toList();
|
||||
|
||||
if (snapshot.buildHash != remoteBuildHash ||
|
||||
mismatchedFiles.isNotEmpty ||
|
||||
extraFiles.isNotEmpty) {
|
||||
throw LauncherSyncException('Client verification failed after update.');
|
||||
}
|
||||
}
|
||||
|
||||
String _buildComparisonMessage({
|
||||
required ClientInstallationSnapshot localSnapshot,
|
||||
required String remoteBuildHash,
|
||||
required int filesToDownload,
|
||||
required int staleFiles,
|
||||
}) {
|
||||
if (localSnapshot.buildHash == remoteBuildHash &&
|
||||
filesToDownload == 0 &&
|
||||
staleFiles == 0) {
|
||||
return 'Local build matches the server manifest.';
|
||||
}
|
||||
|
||||
return 'Update required: $filesToDownload file(s) to download, '
|
||||
'$staleFiles file(s) to remove.';
|
||||
}
|
||||
|
||||
DownloadProgress _fullProgress(ClientManifest manifest) {
|
||||
return DownloadProgress(
|
||||
speed: 0,
|
||||
downloaded: manifest.totalSize,
|
||||
total: manifest.totalSize,
|
||||
eta: Duration.zero,
|
||||
);
|
||||
}
|
||||
|
||||
void _emit(
|
||||
StreamController<ClientSyncStatus> controller,
|
||||
ClientSyncStatus status,
|
||||
) {
|
||||
if (!controller.isClosed) {
|
||||
controller.add(status);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _throwIfCancelled(FutureOr<bool> Function()? isCancelled) async {
|
||||
if (isCancelled != null && await isCancelled()) {
|
||||
throw const CancelledException();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user