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 Function()? isCancelled; const ClientSyncUseCaseInput({required this.request, this.isCancelled}); } @lazySingleton class ClientSyncUseCase implements StreamUseCase { ClientSyncUseCase({ required LauncherApiClient launcherApiClient, required GameInstallationService installationService, }) : _launcherApiClient = launcherApiClient, _installationService = installationService; final LauncherApiClient _launcherApiClient; final GameInstallationService _installationService; @override Stream call(ClientSyncUseCaseInput input) { final controller = StreamController(); unawaited(_run(input, controller)); return controller.stream; } Future _run( ClientSyncUseCaseInput input, StreamController 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 _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 _resolveStaleFiles( ClientManifest manifest, ClientInstallationSnapshot snapshot, ) { final remoteFiles = manifest.filesByPath; return snapshot.files .where((localFile) => !remoteFiles.containsKey(localFile.path)) .map((file) => file.path) .toList(); } Future _removeStaleFiles({ required StreamController controller, required String installationDir, required List staleFiles, required String remoteBuildHash, required FutureOr 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 _downloadChangedFiles({ required StreamController controller, required ClientManifest manifest, required String accessToken, required String installationDir, required List filesToDownload, required String remoteBuildHash, required FutureOr Function()? isCancelled, }) async { final totalBytes = filesToDownload.fold( 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 controller, ClientSyncStatus status, ) { if (!controller.isClosed) { controller.add(status); } } Future _throwIfCancelled(FutureOr Function()? isCancelled) async { if (isCancelled != null && await isCancelled()) { throw const CancelledException(); } } }