From 8ed2b692b68a080fe66467bd93afd0cab7ccd90c Mon Sep 17 00:00:00 2001 From: sindoring Date: Sun, 22 Mar 2026 02:18:40 +0400 Subject: [PATCH] =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/launcher_web_api_spec.md | 16 ++ lib/config.dart | 1 + .../application/client_sync_use_case.dart | 199 +++++++++++++++--- .../data/game_installation_service.dart | 34 +++ .../launcher/data/launcher_api_client.dart | 194 ++++++++++++++++- .../launcher/data/launcher_log_service.dart | 117 ++++++++++ .../client_sync_use_case_test.dart | 124 ++++++++++- .../data/game_installation_service_test.dart | 14 ++ 8 files changed, 652 insertions(+), 47 deletions(-) create mode 100644 lib/features/launcher/data/launcher_log_service.dart diff --git a/docs/launcher_web_api_spec.md b/docs/launcher_web_api_spec.md index 5277421..c4e9c50 100644 --- a/docs/launcher_web_api_spec.md +++ b/docs/launcher_web_api_spec.md @@ -110,6 +110,21 @@ Cache reuse rule: The cache is an optimization only. The server remains the source of truth via the manifest comparison. +## Diagnostics + +During sync, the launcher writes a log file to: + +- `/.moonwell_launcher/launcher.log` + +If an installation directory is not available yet, the launcher falls back to a +temporary system log directory. + +For failed downloads: + +- HTTP and storage download failures are logged with file path and status code +- checksum mismatches log expected and actual `size` and `sha256` +- invalid payloads are preserved as `*.moonwell.part.failed` for inspection + ## buildHash Algorithm `buildHash` is deterministic and calculated identically for local snapshot and @@ -173,6 +188,7 @@ The launcher surfaces errors for: - manifest load failure - path traversal attempts - checksum mismatch after download +- missing downloaded temp files before verification - final verification mismatch after update - missing `Wow.exe` diff --git a/lib/config.dart b/lib/config.dart index 5653c87..9eaed86 100644 --- a/lib/config.dart +++ b/lib/config.dart @@ -2,6 +2,7 @@ final class Config { static const launcherApiBaseUrl = String.fromEnvironment( 'MOONWELL_API_BASE_URL', ); + static const launcherLogFileName = 'launcher.log'; static const gameExecutableName = 'Wow.exe'; static const cacheDirectoryName = 'Cache'; static const launcherMetadataDirectoryName = '.moonwell_launcher'; diff --git a/lib/features/launcher/application/client_sync_use_case.dart b/lib/features/launcher/application/client_sync_use_case.dart index a089051..8afb0fa 100644 --- a/lib/features/launcher/application/client_sync_use_case.dart +++ b/lib/features/launcher/application/client_sync_use_case.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:injectable/injectable.dart'; import 'package:moonwell_launcher/core/use_case.dart'; @@ -6,6 +7,7 @@ import 'package:moonwell_launcher/features/downloader/domain/entities/download_e 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/data/launcher_log_service.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'; @@ -37,11 +39,14 @@ class ClientSyncUseCase ClientSyncUseCase({ required LauncherApiClient launcherApiClient, required GameInstallationService installationService, + LauncherLogService? launcherLogService, }) : _launcherApiClient = launcherApiClient, - _installationService = installationService; + _installationService = installationService, + _launcherLogService = launcherLogService ?? LauncherLogService(); final LauncherApiClient _launcherApiClient; final GameInstallationService _installationService; + final LauncherLogService _launcherLogService; @override Stream call(ClientSyncUseCaseInput input) { @@ -54,7 +59,18 @@ class ClientSyncUseCase ClientSyncUseCaseInput input, StreamController controller, ) async { + String? remoteBuildHash; + try { + await _launcherLogService.info( + 'Starting client sync.', + installationDir: input.request.installationDir, + details: { + 'installationDir': input.request.installationDir, + 'manifestProvided': input.request.manifest != null, + }, + ); + await _throwIfCancelled(input.isCancelled); _emit( @@ -74,7 +90,7 @@ class ClientSyncUseCase final manifest = input.request.manifest ?? await _launcherApiClient.fetchManifest(input.request.accessToken); - final remoteBuildHash = manifest.buildHash; + remoteBuildHash = manifest.buildHash; final localSnapshot = await _installationService.scanInstallation( input.request.installationDir, @@ -190,9 +206,20 @@ class ClientSyncUseCase _ensureVerificationPassed( manifest: manifest, snapshot: verifiedSnapshot, + installationDir: input.request.installationDir, remoteBuildHash: remoteBuildHash, ); + await _launcherLogService.info( + 'Client sync completed successfully.', + installationDir: input.request.installationDir, + details: { + 'localBuildHash': verifiedSnapshot.buildHash, + 'remoteBuildHash': remoteBuildHash, + 'fileCount': manifest.files.length, + }, + ); + _emit( controller, ClientSyncStatus( @@ -207,6 +234,22 @@ class ClientSyncUseCase ), ); } catch (error, stackTrace) { + if (error is CancelledException) { + await _launcherLogService.info( + 'Client sync cancelled.', + installationDir: input.request.installationDir, + details: {'remoteBuildHash': remoteBuildHash}, + ); + } else { + await _launcherLogService.error( + 'Client sync failed.', + installationDir: input.request.installationDir, + details: {'remoteBuildHash': remoteBuildHash}, + error: error, + stackTrace: stackTrace, + ); + } + if (!controller.isClosed) { controller.addError(error, stackTrace); } @@ -349,38 +392,52 @@ class ClientSyncUseCase ); final temporaryPath = '$destinationPath.moonwell.part'; - await _installationService.ensureParentDirectoryExists(temporaryPath); - 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) { + try { + await _installationService.ensureParentDirectoryExists(temporaryPath); await _installationService.deleteFileIfExists(temporaryPath); + + await _launcherApiClient.downloadFile( + installationDir: installationDir, + accessToken: accessToken, + file: file, + destinationPath: temporaryPath, + isCancelled: isCancelled, + onChunkReceived: (chunkBytes) { + downloadedBytes += chunkBytes; + bytesSinceLastTick += chunkBytes; + }, + ); + + await _verifyDownloadedFile( + installationDir: installationDir, + file: file, + temporaryPath: temporaryPath, + ); + + await _installationService.replaceFile( + temporaryPath: temporaryPath, + destinationPath: destinationPath, + ); + + processedFiles += 1; + emitProgress(); + } on FileSystemException catch (error, stackTrace) { + await _launcherLogService.error( + 'Local file operation failed during sync.', + installationDir: installationDir, + details: { + 'filePath': file.path, + 'destinationPath': destinationPath, + 'temporaryPath': temporaryPath, + }, + error: error, + stackTrace: stackTrace, + ); throw LauncherSyncException( - 'Downloaded file failed verification: ${file.path}', + 'Failed to update local file: ${file.path}. ' + 'See ${_launcherLogService.logHint(installationDir)}.', ); } - - await _installationService.replaceFile( - temporaryPath: temporaryPath, - destinationPath: destinationPath, - ); - - processedFiles += 1; - emitProgress(); } } finally { ticker.cancel(); @@ -390,6 +447,7 @@ class ClientSyncUseCase void _ensureVerificationPassed({ required ClientManifest manifest, required ClientInstallationSnapshot snapshot, + required String installationDir, required String remoteBuildHash, }) { final localFilesByPath = snapshot.filesByPath; @@ -408,10 +466,89 @@ class ClientSyncUseCase if (snapshot.buildHash != remoteBuildHash || mismatchedFiles.isNotEmpty || extraFiles.isNotEmpty) { - throw LauncherSyncException('Client verification failed after update.'); + unawaited( + _launcherLogService.error( + 'Final client verification failed after update.', + installationDir: installationDir, + details: { + 'localBuildHash': snapshot.buildHash, + 'remoteBuildHash': remoteBuildHash, + 'mismatchedFiles': mismatchedFiles + .map((file) => file.path) + .take(10) + .toList(), + 'extraFiles': extraFiles.map((file) => file.path).take(10).toList(), + }, + ), + ); + + final firstProblemPath = mismatchedFiles.isNotEmpty + ? mismatchedFiles.first.path + : extraFiles.first.path; + throw LauncherSyncException( + 'Client verification failed after update: $firstProblemPath. ' + 'See ${_launcherLogService.logHint(installationDir)}.', + ); } } + Future _verifyDownloadedFile({ + required String installationDir, + required ClientManifestFile file, + required String temporaryPath, + }) async { + final actualSize = await _installationService.getFileSizeIfExists( + temporaryPath, + ); + if (actualSize == null) { + await _launcherLogService.error( + 'Downloaded file is missing before verification.', + installationDir: installationDir, + details: { + 'filePath': file.path, + 'temporaryPath': temporaryPath, + 'expectedSize': file.size, + 'expectedSha256': file.sha256, + }, + ); + throw LauncherSyncException( + 'Downloaded file is missing: ${file.path}. ' + 'See ${_launcherLogService.logHint(installationDir)}.', + ); + } + + final actualSha256 = await _installationService.computeSha256( + temporaryPath, + ); + final expectedSha256 = file.sha256.toLowerCase(); + final normalizedActualSha256 = actualSha256.toLowerCase(); + + if (actualSize == file.size && normalizedActualSha256 == expectedSha256) { + return; + } + + final preservedPath = await _installationService.preserveFailedDownload( + temporaryPath, + ); + await _launcherLogService.error( + 'Downloaded file failed verification.', + installationDir: installationDir, + details: { + 'filePath': file.path, + 'temporaryPath': temporaryPath, + 'preservedPath': preservedPath, + 'expectedSize': file.size, + 'actualSize': actualSize, + 'expectedSha256': file.sha256, + 'actualSha256': actualSha256, + }, + ); + throw LauncherSyncException( + 'Downloaded file failed verification: ${file.path}. ' + 'See ${_launcherLogService.logHint(installationDir)}.', + ); + } + String _buildComparisonMessage({ required ClientInstallationSnapshot localSnapshot, required String remoteBuildHash, diff --git a/lib/features/launcher/data/game_installation_service.dart b/lib/features/launcher/data/game_installation_service.dart index e41b9e3..c2617ea 100644 --- a/lib/features/launcher/data/game_installation_service.dart +++ b/lib/features/launcher/data/game_installation_service.dart @@ -242,6 +242,13 @@ class GameInstallationService { ); } + String getLauncherLogPath(String installationDir) { + return p.join( + getLauncherMetadataDirectoryPath(installationDir), + Config.launcherLogFileName, + ); + } + String getExecutablePath(String installationDir) { return p.join(installationDir, Config.gameExecutableName); } @@ -285,6 +292,33 @@ class GameInstallationService { return const {}; } } + + Future getFileSizeIfExists(String filePath) async { + final file = File(filePath); + if (!await file.exists()) { + return null; + } + + return file.stat().then((stat) => stat.size); + } + + Future preserveFailedDownload(String temporaryPath) async { + final temporaryFile = File(temporaryPath); + if (!await temporaryFile.exists()) { + return null; + } + + final preservedPath = '$temporaryPath.failed'; + final preservedFile = File(preservedPath); + await preservedFile.parent.create(recursive: true); + + if (await preservedFile.exists()) { + await preservedFile.delete(); + } + + await temporaryFile.rename(preservedPath); + return preservedPath; + } } Future _scanInstallationIsolateMain(Map message) async { diff --git a/lib/features/launcher/data/launcher_api_client.dart b/lib/features/launcher/data/launcher_api_client.dart index df162a6..0b793d9 100644 --- a/lib/features/launcher/data/launcher_api_client.dart +++ b/lib/features/launcher/data/launcher_api_client.dart @@ -1,10 +1,12 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:injectable/injectable.dart'; import 'package:moonwell_launcher/config.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart'; +import 'package:moonwell_launcher/features/launcher/data/launcher_log_service.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/launcher_exception.dart'; @@ -12,8 +14,9 @@ import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_ses @lazySingleton class LauncherApiClient { - LauncherApiClient() - : _dio = Dio( + LauncherApiClient({LauncherLogService? launcherLogService}) + : _launcherLogService = launcherLogService ?? LauncherLogService(), + _dio = Dio( BaseOptions( connectTimeout: const Duration(seconds: 30), sendTimeout: const Duration(seconds: 30), @@ -23,52 +26,121 @@ class LauncherApiClient { ); final Dio _dio; + final LauncherLogService _launcherLogService; Future login({ required String username, required String password, }) async { + final loginUri = _resolveUri('api/launcher/login'); + try { final response = await _dio.postUri( - _resolveUri('api/launcher/login'), + loginUri, data: {'username': username, 'password': password}, ); return LauncherSession.fromJson(_coerceMap(response.data)); - } on DioException catch (error) { + } on DioException catch (error, stackTrace) { + await _launcherLogService.error( + 'Launcher login request failed.', + details: { + 'endpoint': loginUri.toString(), + 'statusCode': error.response?.statusCode, + 'dioType': error.type.name, + 'responseBody': _summarizePayload(error.response?.data), + }, + error: error, + stackTrace: stackTrace, + ); throw LauncherApiException(_extractErrorMessage(error)); } } Future fetchManifest(String accessToken) async { + final manifestUri = _resolveUri('api/launcher/manifest'); + try { final response = await _dio.getUri( - _resolveUri('api/launcher/manifest'), + manifestUri, options: _authorizedOptions(accessToken), ); return ClientManifest.fromJson(_coerceMap(response.data)); - } on DioException catch (error) { + } on DioException catch (error, stackTrace) { + await _launcherLogService.error( + 'Launcher manifest request failed.', + details: { + 'endpoint': manifestUri.toString(), + 'statusCode': error.response?.statusCode, + 'dioType': error.type.name, + 'responseBody': _summarizePayload(error.response?.data), + }, + error: error, + stackTrace: stackTrace, + ); throw LauncherApiException(_extractErrorMessage(error)); } } Future downloadFile({ + required String installationDir, required String accessToken, required ClientManifestFile file, required String destinationPath, required FutureOr Function()? isCancelled, required void Function(int chunkBytes) onChunkReceived, }) async { + final downloadLinkUri = _resolveUri( + 'api/launcher/download/${Uri.encodeComponent(file.path)}', + ); + Uri? downloadUri; + try { final downloadLinkResponse = await _dio.getUri( - _resolveUri('api/launcher/download/${Uri.encodeComponent(file.path)}'), + downloadLinkUri, options: _authorizedOptions(accessToken), ); - final downloadUri = _extractDownloadUri( - _coerceMap(downloadLinkResponse.data), + downloadUri = _extractDownloadUri(_coerceMap(downloadLinkResponse.data)); + } on DioException catch (error, stackTrace) { + await _launcherLogService.error( + 'Failed to resolve presigned download URL.', + installationDir: installationDir, + details: { + 'filePath': file.path, + 'endpoint': downloadLinkUri.toString(), + 'statusCode': error.response?.statusCode, + 'dioType': error.type.name, + 'responseBody': _summarizePayload(error.response?.data), + }, + error: error, + stackTrace: stackTrace, ); + throw LauncherApiException( + _buildDownloadErrorMessage( + error: error, + filePath: file.path, + logHint: _launcherLogService.logHint(installationDir), + stage: 'resolve download link for', + ), + ); + } on LauncherApiException catch (error, stackTrace) { + await _launcherLogService.error( + 'Launcher API returned an invalid presigned download payload.', + installationDir: installationDir, + details: { + 'filePath': file.path, + 'endpoint': downloadLinkUri.toString(), + }, + error: error, + stackTrace: stackTrace, + ); + throw LauncherApiException( + '${error.message} See ${_launcherLogService.logHint(installationDir)}.', + ); + } + try { final response = await _dio.getUri( downloadUri, options: Options( @@ -98,8 +170,58 @@ class LauncherApiClient { } finally { await sink.close(); } - } on DioException catch (error) { - throw LauncherApiException(_extractErrorMessage(error)); + } on DioException catch (error, stackTrace) { + await _launcherLogService.error( + 'Failed to download file payload.', + installationDir: installationDir, + details: { + 'filePath': file.path, + 'downloadUrl': downloadUri.replace(query: '').toString(), + 'statusCode': error.response?.statusCode, + 'dioType': error.type.name, + 'responseBody': _summarizePayload(error.response?.data), + }, + error: error, + stackTrace: stackTrace, + ); + throw LauncherApiException( + _buildDownloadErrorMessage( + error: error, + filePath: file.path, + logHint: _launcherLogService.logHint(installationDir), + stage: 'download', + ), + ); + } on LauncherApiException catch (error, stackTrace) { + await _launcherLogService.error( + 'Downloaded file payload was invalid.', + installationDir: installationDir, + details: { + 'filePath': file.path, + 'downloadUrl': downloadUri.replace(query: '').toString(), + }, + error: error, + stackTrace: stackTrace, + ); + throw LauncherApiException( + '${error.message} See ${_launcherLogService.logHint(installationDir)}.', + ); + } on FileSystemException catch (error, stackTrace) { + await _launcherLogService.error( + 'Failed to write downloaded file to disk.', + installationDir: installationDir, + details: { + 'filePath': file.path, + 'destinationPath': destinationPath, + 'downloadUrl': downloadUri.replace(query: '').toString(), + }, + error: error, + stackTrace: stackTrace, + ); + throw LauncherSyncException( + 'Failed to write downloaded file: ${file.path}. ' + 'See ${_launcherLogService.logHint(installationDir)}.', + ); } } @@ -181,6 +303,56 @@ class LauncherApiClient { return error.message ?? 'Launcher API request failed.'; } + String _buildDownloadErrorMessage({ + required DioException error, + required String filePath, + required String logHint, + required String stage, + }) { + final statusCode = error.response?.statusCode; + + if (statusCode == HttpStatus.notFound) { + return 'File not found on the server: $filePath. See $logHint.'; + } + + if (error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.receiveTimeout || + error.type == DioExceptionType.sendTimeout || + error.error is SocketException) { + return 'Network error while trying to $stage file: $filePath. ' + 'See $logHint.'; + } + + return 'Failed to $stage file: $filePath. See $logHint.'; + } + + String? _summarizePayload(Object? data) { + if (data == null) { + return null; + } + + final serialized = switch (data) { + String value => value, + Map() || List() => _safeJsonEncode(data), + _ => data.toString(), + }; + + const maxLength = 1000; + if (serialized.length <= maxLength) { + return serialized; + } + + return '${serialized.substring(0, maxLength)}...'; + } + + String _safeJsonEncode(Object? value) { + try { + return jsonEncode(value); + } catch (_) { + return value.toString(); + } + } + Uri _extractDownloadUri(Map payload) { final rawUrl = payload['url']; if (rawUrl is! String || rawUrl.trim().isEmpty) { diff --git a/lib/features/launcher/data/launcher_log_service.dart b/lib/features/launcher/data/launcher_log_service.dart new file mode 100644 index 0000000..21f7817 --- /dev/null +++ b/lib/features/launcher/data/launcher_log_service.dart @@ -0,0 +1,117 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:moonwell_launcher/config.dart'; +import 'package:path/path.dart' as p; + +class LauncherLogService { + static Future _pendingWrite = Future.value(); + + Future info( + String message, { + String? installationDir, + Map details = const {}, + }) { + return _write( + level: 'INFO', + message: message, + installationDir: installationDir, + details: details, + ); + } + + Future error( + String message, { + String? installationDir, + Map details = const {}, + Object? error, + StackTrace? stackTrace, + }) { + return _write( + level: 'ERROR', + message: message, + installationDir: installationDir, + details: details, + error: error, + stackTrace: stackTrace, + ); + } + + Future resolveLogPath({String? installationDir}) async { + final logDirectory = + installationDir == null || installationDir.trim().isEmpty + ? p.join(Directory.systemTemp.path, 'moonwell_launcher') + : p.join(installationDir, Config.launcherMetadataDirectoryName); + + await Directory(logDirectory).create(recursive: true); + return p.join(logDirectory, Config.launcherLogFileName); + } + + String logHint([String? installationDir]) { + if (installationDir == null || installationDir.trim().isEmpty) { + return Config.launcherLogFileName; + } + + return '${Config.launcherMetadataDirectoryName}/${Config.launcherLogFileName}'; + } + + Future _write({ + required String level, + required String message, + required String? installationDir, + required Map details, + Object? error, + StackTrace? stackTrace, + }) { + final operation = _pendingWrite.then((_) async { + final logPath = await resolveLogPath(installationDir: installationDir); + final buffer = StringBuffer() + ..writeln('[${DateTime.now().toIso8601String()}] [$level] $message'); + + for (final entry in details.entries) { + if (entry.value == null) { + continue; + } + + buffer.writeln('${entry.key}: ${_stringify(entry.value)}'); + } + + if (error != null) { + buffer.writeln('error: ${_stringify(error)}'); + } + + if (stackTrace != null) { + buffer.writeln('stackTrace: $stackTrace'); + } + + buffer.writeln(); + await File( + logPath, + ).writeAsString(buffer.toString(), mode: FileMode.append, flush: true); + }); + + _pendingWrite = operation.catchError((_) {}); + return operation; + } + + String _stringify(Object? value) { + if (value == null) { + return 'null'; + } + + if (value is String) { + return value; + } + + if (value is Map || value is Iterable) { + try { + return jsonEncode(value); + } catch (_) { + return value.toString(); + } + } + + return value.toString(); + } +} diff --git a/test/features/launcher/application/client_sync_use_case_test.dart b/test/features/launcher/application/client_sync_use_case_test.dart index c9271ee..8568578 100644 --- a/test/features/launcher/application/client_sync_use_case_test.dart +++ b/test/features/launcher/application/client_sync_use_case_test.dart @@ -6,10 +6,12 @@ import 'package:moonwell_launcher/features/downloader/domain/entities/download_p import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.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/data/launcher_log_service.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'; import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart'; void main() { @@ -42,9 +44,11 @@ void main() { final installationService = _FakeGameInstallationService([ matchingSnapshot, ]); + final logger = _FakeLauncherLogService(); final useCase = ClientSyncUseCase( launcherApiClient: api, installationService: installationService, + launcherLogService: logger, ); final statuses = await useCase @@ -63,6 +67,7 @@ void main() { expect(statuses.last.message, 'Client is up to date.'); expect(api.downloadedPaths, isEmpty); expect(installationService.deletedRelativeFiles, isEmpty); + expect(logger.errorMessages, isEmpty); }, ); @@ -95,13 +100,20 @@ void main() { ]); final api = _FakeLauncherApiClient(manifest); - final installationService = _FakeGameInstallationService([ - initialSnapshot, - verifiedSnapshot, - ]); + final installationService = + _FakeGameInstallationService([initialSnapshot, verifiedSnapshot]) + ..fileSizesByPath['C:/MoonWell/Wow.exe.moonwell.part'] = 5 + ..computedHashesByPath['C:/MoonWell/Wow.exe.moonwell.part'] = + 'wow-hash' + ..fileSizesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] = + 10 + ..computedHashesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] = + 'data-hash'; + final logger = _FakeLauncherLogService(); final useCase = ClientSyncUseCase( launcherApiClient: api, installationService: installationService, + launcherLogService: logger, ); final statuses = await useCase @@ -128,6 +140,66 @@ void main() { expect(installationService.deletedRelativeFiles, ['legacy.txt']); expect(api.downloadedPaths, ['Wow.exe', 'Data/common.MPQ']); expect(statuses.last.message, 'Client is ready to play.'); + expect(logger.errorMessages, isEmpty); + }, + ); + + test( + 'preserves failed temporary file and returns a log hint on verification mismatch', + () async { + final manifest = _manifestFromFiles([ + const ClientManifestFile( + path: 'Data/ruRU/realmlist.wtf', + size: 12, + sha256: 'expected-sha', + ), + ]); + final initialSnapshot = ClientInstallationSnapshot.fromFiles(const []); + final installationService = + _FakeGameInstallationService([initialSnapshot]) + ..fileSizesByPath['C:/MoonWell/Data/ruRU/realmlist.wtf.moonwell.part'] = + 12 + ..computedHashesByPath['C:/MoonWell/Data/ruRU/realmlist.wtf.moonwell.part'] = + 'actual-sha'; + final logger = _FakeLauncherLogService(); + final useCase = ClientSyncUseCase( + launcherApiClient: _FakeLauncherApiClient(manifest), + installationService: installationService, + launcherLogService: logger, + ); + + final future = useCase + .call( + ClientSyncUseCaseInput( + request: ClientSyncRequest( + installationDir: 'C:/MoonWell', + accessToken: 'token', + manifest: manifest, + ), + ), + ) + .drain(); + + await expectLater( + future, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains( + 'Downloaded file failed verification: Data/ruRU/realmlist.wtf. ' + 'See .moonwell_launcher/launcher.log.', + ), + ), + ), + ); + expect(installationService.preservedFailedDownloads, [ + 'C:/MoonWell/Data/ruRU/realmlist.wtf.moonwell.part', + ]); + expect( + logger.errorMessages, + contains('Downloaded file failed verification.'), + ); }, ); }); @@ -158,6 +230,7 @@ class _FakeLauncherApiClient extends LauncherApiClient { @override Future downloadFile({ + required String installationDir, required String accessToken, required ClientManifestFile file, required String destinationPath, @@ -178,6 +251,9 @@ class _FakeGameInstallationService extends GameInstallationService { final List deletedRelativeFiles = []; final List deletedFiles = []; final List replacedDestinations = []; + final List preservedFailedDownloads = []; + final Map computedHashesByPath = {}; + final Map fileSizesByPath = {}; @override Future scanInstallation( @@ -232,7 +308,20 @@ class _FakeGameInstallationService extends GameInstallationService { } @override - Future verifySha256(String filePath, String expectedHash) async => true; + Future computeSha256(String filePath) async { + return computedHashesByPath[filePath] ?? 'computed-hash'; + } + + @override + Future getFileSizeIfExists(String filePath) async { + return fileSizesByPath[filePath]; + } + + @override + Future preserveFailedDownload(String temporaryPath) async { + preservedFailedDownloads.add(temporaryPath); + return '$temporaryPath.failed'; + } @override Future replaceFile({ @@ -242,3 +331,28 @@ class _FakeGameInstallationService extends GameInstallationService { replacedDestinations.add(destinationPath); } } + +class _FakeLauncherLogService extends LauncherLogService { + final List infoMessages = []; + final List errorMessages = []; + + @override + Future info( + String message, { + String? installationDir, + Map details = const {}, + }) async { + infoMessages.add(message); + } + + @override + Future error( + String message, { + String? installationDir, + Map details = const {}, + Object? error, + StackTrace? stackTrace, + }) async { + errorMessages.add(message); + } +} diff --git a/test/features/launcher/data/game_installation_service_test.dart b/test/features/launcher/data/game_installation_service_test.dart index e808268..a986b42 100644 --- a/test/features/launcher/data/game_installation_service_test.dart +++ b/test/features/launcher/data/game_installation_service_test.dart @@ -138,6 +138,20 @@ void main() { ); }); + test('preserveFailedDownload renames temp file to failed artifact', () async { + final temporaryPath = + '${rootDirectory.path}${Platform.pathSeparator}Data${Platform.pathSeparator}broken.MPQ.moonwell.part'; + final temporaryFile = File(temporaryPath); + await temporaryFile.parent.create(recursive: true); + await temporaryFile.writeAsString('broken'); + + final preservedPath = await service.preserveFailedDownload(temporaryPath); + + expect(preservedPath, '$temporaryPath.failed'); + expect(await temporaryFile.exists(), isFalse); + expect(await File('$temporaryPath.failed').exists(), isTrue); + }); + test('resolveClientPath rejects path traversal', () { expect( () => service.resolveClientPath(rootDirectory.path, '../evil.txt'),