diff --git a/docs/launcher_web_api_spec.md b/docs/launcher_web_api_spec.md new file mode 100644 index 0000000..5277421 --- /dev/null +++ b/docs/launcher_web_api_spec.md @@ -0,0 +1,189 @@ +# Moonwell Launcher Web API Spec + +## Scope + +This document specifies the current launcher behavior for: + +- authentication against the Moonwell Web API +- installation directory selection +- local file scan and hash calculation +- manifest-based synchronization +- client launch from `Wow.exe` + +The launcher no longer uses S3/MinIO directly. All server interaction goes +through the Web API. + +## Configuration + +The launcher API base URL is provided via: + +- `MOONWELL_API_BASE_URL` + +Current integration mode is compile-time: + +- `flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://host` +- `flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host` + +## API Flow + +The launcher uses these endpoints from `openapi.json`: + +1. `POST /api/launcher/login` +2. `GET /api/launcher/manifest` +3. `GET /api/launcher/download/{path}` + +Authentication sequence: + +1. User enters launcher credentials. +2. Launcher requests a bearer token from `POST /api/launcher/login`. +3. Launcher requests the manifest from `GET /api/launcher/manifest`. +4. `LauncherSession` and `ClientManifest` are passed into the home screen. + +File download sequence: + +1. Launcher resolves files that are missing, stale, or changed. +2. For each required file, launcher calls `GET /api/launcher/download/{path}` with bearer auth. +3. API returns JSON with a temporary presigned `url` and `expires_in`. +4. Launcher downloads the file directly from storage using the presigned URL. +5. Download is written into `*.moonwell.part`. +6. Downloaded file is verified against manifest `sha256`. +7. Temporary file replaces the destination file only after successful verify. + +## Installation Directory Rules + +The user selects a single installation root directory. + +All paths in the manifest are treated as relative to that root. + +Path safety rules: + +- backslashes are normalized to `/` +- leading `./` is removed +- absolute paths are rejected +- path traversal via `..` is rejected + +## Ignored Directories + +The launcher excludes these top-level directories from verification: + +- `Cache` +- `Errors` +- `Logs` +- `Screenshots` +- `WTF` + +Files under these directories: + +- are not included in local scan +- do not participate in local `buildHash` +- are not compared against server manifest + +## Local Scan + +For every non-ignored file in the installation directory, the launcher computes: + +- normalized relative path +- file size in bytes +- file `sha256` + +The result is stored as a `ClientInstallationSnapshot`. + +## Hash Cache + +To accelerate repeated scans, the launcher stores a local hash cache in: + +- `/.moonwell_launcher/hash_cache.json` + +This metadata directory is excluded from verification. + +Each cache entry is keyed by normalized relative path and stores: + +- `size` +- `modifiedMs` +- `sha256` + +Cache reuse rule: + +- if `path`, `size`, and `modifiedMs` still match, the cached `sha256` is reused +- otherwise `sha256` is recomputed and the cache entry is replaced + +The cache is an optimization only. The server remains the source of truth via +the manifest comparison. + +## buildHash Algorithm + +`buildHash` is deterministic and calculated identically for local snapshot and +remote manifest. + +For each file entry, the launcher builds a canonical string: + +`::` + +Then: + +1. sort all canonical strings lexicographically +2. join them with `\n` +3. compute `sha256` of the resulting UTF-8 payload + +This means file ordering in the manifest does not affect `buildHash`. + +## Sync Algorithm + +The current sync flow is: + +1. Load manifest. +2. Scan local installation. +3. Compute local `buildHash`. +4. Compare local files to manifest files by `path`, `size`, and `sha256`. +5. Identify stale local files that are not present in the manifest. +6. If local `buildHash` matches server `buildHash` and there are no stale files + and no mismatches, finish successfully. +7. Delete stale local files outside ignored directories. +8. Download changed and missing files. +9. Verify every downloaded file by `sha256`. +10. Re-scan the installation. +11. Recompute local `buildHash`. +12. Fail if final snapshot still differs from manifest. + +## Pause and Resume Semantics + +Pause is implemented as cooperative cancellation: + +- the current sync stream is cancelled +- the next sync starts a fresh comparison from current disk state +- already replaced files remain valid +- partial `*.moonwell.part` files are removed before the next download attempt + +## Launch Behavior + +When the user presses `Play`: + +1. launcher resolves `/Wow.exe` +2. launcher clears `/Cache` +3. launcher starts `Wow.exe` with working directory set to installation root + +If `Wow.exe` is missing, launch fails with an error. + +## Error Behavior + +The launcher surfaces errors for: + +- invalid API configuration +- authentication failure +- manifest load failure +- path traversal attempts +- checksum mismatch after download +- final verification mismatch after update +- missing `Wow.exe` + +## Tested Invariants + +The automated tests cover: + +- deterministic `buildHash` +- path normalization used by `buildHash` +- exclusion of ignored directories during local scan +- cache cleanup behavior +- safe path resolution +- sync flow with fully up-to-date client +- sync flow with stale files and changed files diff --git a/lib/config.dart b/lib/config.dart index 14b02fb..5653c87 100644 --- a/lib/config.dart +++ b/lib/config.dart @@ -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 = { 'cache', 'errors', 'logs', 'screenshots', 'wtf', + '.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 67dcbcc..a089051 100644 --- a/lib/features/launcher/application/client_sync_use_case.dart +++ b/lib/features/launcher/application/client_sync_use_case.dart @@ -349,6 +349,7 @@ class ClientSyncUseCase ); final temporaryPath = '$destinationPath.moonwell.part'; + await _installationService.ensureParentDirectoryExists(temporaryPath); await _installationService.deleteFileIfExists(temporaryPath); await _launcherApiClient.downloadFile( diff --git a/lib/features/launcher/data/game_installation_service.dart b/lib/features/launcher/data/game_installation_service.dart index b365970..e41b9e3 100644 --- a/lib/features/launcher/data/game_installation_service.dart +++ b/lib/features/launcher/data/game_installation_service.dart @@ -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( - 0, - (sum, pendingFile) => sum + pendingFile.size, - ); - + final receivePort = ReceivePort(); + final errorPort = ReceivePort(); + final exitPort = ReceivePort(); final files = []; - var processedBytes = 0; - var processedFiles = 0; + final completer = Completer(); + final hashCachePath = getHashCachePath(installationDir); + final cachedHashes = await loadHashCache(installationDir); + Isolate? isolate; + StreamSubscription? receiveSubscription; + StreamSubscription? errorSubscription; + StreamSubscription? 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 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>( + _scanInstallationIsolateMain, + { + '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 computeSha256(String filePath) async { @@ -109,6 +173,10 @@ class GameInstallationService { } } + Future ensureParentDirectoryExists(String filePath) { + return File(filePath).parent.create(recursive: true); + } + Future 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 _collectFiles( - Directory directory, - String rootPath, - List<_PendingFile> pendingFiles, { - FutureOr Function()? isCancelled, - }) async { - await for (final entity in directory.list(followLinks: false)) { - await _throwIfCancelled(isCancelled); + Future> loadHashCache(String installationDir) async { + final cacheFile = File(getHashCachePath(installationDir)); + if (!await cacheFile.exists()) { + return const {}; + } - 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 {}; } - if (_isExcludedPath(relativePath)) { - continue; + final entries = decoded['entries']; + if (entries is! Map) { + return const {}; } - if (entity is Directory) { - await _collectFiles( - entity, - rootPath, - pendingFiles, - isCancelled: isCancelled, - ); - continue; - } + return entries.map((key, value) => MapEntry(key.toString(), value)); + } catch (_) { + return const {}; + } + } +} - if (entity is! File) { - continue; - } +Future _scanInstallationIsolateMain(Map 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).map( + (key, value) => MapEntry(key.toString(), value), + ); + final ignoredDirectories = (message['ignoredDirectories'] as List) + .whereType() + .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({'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( + 0, + (sum, pendingFile) => sum + pendingFile.size, + ); + final updatedCacheEntries = >{}; + + 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] = { + 'size': pendingFile.size, + 'modifiedMs': pendingFile.modifiedMs, + 'sha256': digest, + }; + + sendPort.send({ + '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({'type': 'done'}); +} + +Future _resolveFileHash( + _SerializablePendingFile pendingFile, + Map 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 _collectFilesInIsolate( + Directory directory, + String rootPath, + List<_SerializablePendingFile> pendingFiles, + Set 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 _throwIfCancelled(FutureOr Function()? isCancelled) async { - if (isCancelled != null && await isCancelled()) { - throw const CancelledException(); - } - } } -final class _PendingFile { - final File file; +Future _writeHashCache( + String hashCachePath, + Map> entries, +) async { + final cacheFile = File(hashCachePath); + await cacheFile.parent.create(recursive: true); + + final tempFile = File('$hashCachePath.tmp'); + final payload = jsonEncode({ + '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, }); } diff --git a/lib/features/launcher/data/launcher_api_client.dart b/lib/features/launcher/data/launcher_api_client.dart index 0162d57..df162a6 100644 --- a/lib/features/launcher/data/launcher_api_client.dart +++ b/lib/features/launcher/data/launcher_api_client.dart @@ -61,10 +61,17 @@ class LauncherApiClient { required void Function(int chunkBytes) onChunkReceived, }) async { try { - final response = await _dio.getUri( + 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( + 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 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; + } } diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..73ad565 --- /dev/null +++ b/openapi.json @@ -0,0 +1,299 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Moonwell Launcher API", + "description": "API для лаунчера WoW-клиента Moonwell. Авторизация, получение манифеста файлов и скачивание обновлений.", + "version": "1.0.0" + }, + "paths": { + "/api/launcher/login": { + "post": { + "tags": [ + "Launcher Auth" + ], + "summary": "Авторизация лаунчера", + "description": "Авторизация по логину и паролю игрового аккаунта. Возвращает Bearer-токен для доступа к API.", + "operationId": "8b934358769733a6925c1fbc8dc07705", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "type": "string", + "pattern": "^[A-Za-z0-9]+$", + "example": "Player", + "maxLength": 32, + "minLength": 3 + }, + "password": { + "type": "string", + "example": "secret123", + "maxLength": 32, + "minLength": 3 + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Успешная авторизация", + "content": { + "application/json": { + "schema": { + "properties": { + "access_token": { + "description": "JWT access token", + "type": "string" + }, + "token_type": { + "type": "string", + "example": "Bearer" + }, + "expires_at": { + "type": "string", + "format": "date-time", + "example": "2026-04-20T13:00:00+00:00" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Неверный логин или пароль", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Неверный логин или пароль." + } + }, + "type": "object" + } + } + } + }, + "422": { + "description": "Ошибка валидации", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + }, + "errors": { + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "429": { + "description": "Слишком много запросов" + } + } + } + }, + "/api/launcher/manifest": { + "get": { + "tags": [ + "Launcher" + ], + "summary": "Получить манифест файлов клиента", + "description": "Возвращает JSON-манифест со списком файлов клиента, их размерами и SHA-256 хешами. Лаунчер сверяет локальные файлы с манифестом и докачивает недостающие.", + "operationId": "2c449e4bb08c2d4c838e36d260a3b61d", + "responses": { + "200": { + "description": "Манифест файлов", + "content": { + "application/json": { + "schema": { + "properties": { + "files": { + "type": "array", + "items": { + "properties": { + "path": { + "type": "string", + "example": "Data/common.MPQ" + }, + "size": { + "type": "integer", + "format": "int64", + "example": 2881154862 + }, + "sha256": { + "type": "string", + "format": "hex", + "example": "d850ffa5efd6a1ba845899a7d9f9ad27f6eea7ac606e95033506c72c86ee0236" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Не авторизован", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Unauthenticated." + } + }, + "type": "object" + } + } + } + }, + "404": { + "description": "Манифест не найден в хранилище", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Манифест не найден." + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/launcher/download/{path}": { + "get": { + "tags": [ + "Launcher" + ], + "summary": "Получить ссылку на скачивание файла", + "description": "Возвращает временную presigned-ссылку на файл в S3. Лаунчер скачивает файл напрямую из хранилища по этой ссылке. Путь может содержать вложенные директории (например `Data/ruRU/patch-ruRU-3.MPQ`).", + "operationId": "9a94331833a7af795c1c57dfa3a77eb3", + "parameters": [ + { + "name": "path", + "in": "path", + "description": "Относительный путь к файлу из манифеста", + "required": true, + "schema": { + "type": "string" + }, + "example": "Data/common.MPQ" + } + ], + "responses": { + "200": { + "description": "Временная ссылка на скачивание", + "content": { + "application/json": { + "schema": { + "properties": { + "url": { + "description": "Presigned URL для скачивания", + "type": "string", + "format": "uri" + }, + "expires_in": { + "description": "Время жизни ссылки в секундах", + "type": "integer", + "example": 3600 + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Не авторизован", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Unauthenticated." + } + }, + "type": "object" + } + } + } + }, + "404": { + "description": "Файл не найден", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Файл не найден." + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "description": "Токен, полученный через POST /api/launcher/login", + "bearerFormat": "JWT", + "scheme": "bearer" + } + } + }, + "tags": [ + { + "name": "Launcher Auth", + "description": "Launcher Auth" + }, + { + "name": "Launcher", + "description": "Launcher" + } + ] +} \ No newline at end of file diff --git a/test/features/launcher/application/client_sync_use_case_test.dart b/test/features/launcher/application/client_sync_use_case_test.dart new file mode 100644 index 0000000..c9271ee --- /dev/null +++ b/test/features/launcher/application/client_sync_use_case_test.dart @@ -0,0 +1,244 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart'; +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/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/local_client_file.dart'; + +void main() { + group('ClientSyncUseCase', () { + test( + 'completes without downloads when local snapshot matches manifest', + () async { + final manifest = _manifestFromFiles([ + const ClientManifestFile( + path: 'Wow.exe', + size: 5, + sha256: 'wow-hash', + ), + const ClientManifestFile( + path: 'Data/common.MPQ', + size: 10, + sha256: 'data-hash', + ), + ]); + final matchingSnapshot = ClientInstallationSnapshot.fromFiles([ + const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'), + const LocalClientFile( + path: 'Data/common.MPQ', + size: 10, + sha256: 'data-hash', + ), + ]); + + final api = _FakeLauncherApiClient(manifest); + final installationService = _FakeGameInstallationService([ + matchingSnapshot, + ]); + final useCase = ClientSyncUseCase( + launcherApiClient: api, + installationService: installationService, + ); + + final statuses = await useCase + .call( + ClientSyncUseCaseInput( + request: ClientSyncRequest( + installationDir: 'C:/MoonWell', + accessToken: 'token', + manifest: manifest, + ), + ), + ) + .toList(); + + expect(statuses.last.stage, ClientSyncStage.completed); + expect(statuses.last.message, 'Client is up to date.'); + expect(api.downloadedPaths, isEmpty); + expect(installationService.deletedRelativeFiles, isEmpty); + }, + ); + + test( + 'removes stale files and downloads changed files before final verify', + () async { + final manifest = _manifestFromFiles([ + const ClientManifestFile( + path: 'Wow.exe', + size: 5, + sha256: 'wow-hash', + ), + const ClientManifestFile( + path: 'Data/common.MPQ', + size: 10, + sha256: 'data-hash', + ), + ]); + final initialSnapshot = ClientInstallationSnapshot.fromFiles([ + const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'old-wow'), + const LocalClientFile(path: 'legacy.txt', size: 2, sha256: 'legacy'), + ]); + final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([ + const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'), + const LocalClientFile( + path: 'Data/common.MPQ', + size: 10, + sha256: 'data-hash', + ), + ]); + + final api = _FakeLauncherApiClient(manifest); + final installationService = _FakeGameInstallationService([ + initialSnapshot, + verifiedSnapshot, + ]); + final useCase = ClientSyncUseCase( + launcherApiClient: api, + installationService: installationService, + ); + + final statuses = await useCase + .call( + ClientSyncUseCaseInput( + request: ClientSyncRequest( + installationDir: 'C:/MoonWell', + accessToken: 'token', + manifest: manifest, + ), + ), + ) + .toList(); + + expect( + statuses.map((status) => status.stage), + containsAll([ + ClientSyncStage.removingStaleFiles, + ClientSyncStage.downloadingFiles, + ClientSyncStage.verifyingInstallation, + ClientSyncStage.completed, + ]), + ); + expect(installationService.deletedRelativeFiles, ['legacy.txt']); + expect(api.downloadedPaths, ['Wow.exe', 'Data/common.MPQ']); + expect(statuses.last.message, 'Client is ready to play.'); + }, + ); + }); +} + +ClientManifest _manifestFromFiles(List files) { + return ClientManifest.fromJson({ + 'files': files + .map( + (file) => { + 'path': file.path, + 'size': file.size, + 'sha256': file.sha256, + }, + ) + .toList(), + }); +} + +class _FakeLauncherApiClient extends LauncherApiClient { + _FakeLauncherApiClient(this._manifest); + + final ClientManifest _manifest; + final List downloadedPaths = []; + + @override + Future fetchManifest(String accessToken) async => _manifest; + + @override + Future downloadFile({ + required String accessToken, + required ClientManifestFile file, + required String destinationPath, + required FutureOr Function()? isCancelled, + required void Function(int chunkBytes) onChunkReceived, + }) async { + downloadedPaths.add(file.path); + onChunkReceived(file.size); + } +} + +class _FakeGameInstallationService extends GameInstallationService { + _FakeGameInstallationService(List snapshots) + : _snapshots = Queue.of(snapshots); + + final Queue _snapshots; + final List ensuredParentDirectories = []; + final List deletedRelativeFiles = []; + final List deletedFiles = []; + final List replacedDestinations = []; + + @override + Future scanInstallation( + String installationDir, { + InstallationScanProgressCallback? onProgress, + FutureOr Function()? isCancelled, + }) async { + final snapshot = _snapshots.removeFirst(); + var processedFiles = 0; + var processedBytes = 0; + + for (final file in snapshot.files) { + processedFiles += 1; + processedBytes += file.size; + onProgress?.call( + DownloadProgress( + speed: 0, + downloaded: processedBytes, + total: snapshot.files.fold(0, (sum, item) => sum + item.size), + eta: Duration.zero, + ), + file.path, + processedFiles, + snapshot.files.length, + ); + } + + return snapshot; + } + + @override + Future deleteRelativeFile( + String installationDir, + String relativePath, + ) async { + deletedRelativeFiles.add(relativePath); + } + + @override + String resolveClientPath(String installationDir, String relativePath) { + return '$installationDir/$relativePath'; + } + + @override + Future ensureParentDirectoryExists(String filePath) async { + ensuredParentDirectories.add(filePath); + } + + @override + Future deleteFileIfExists(String filePath) async { + deletedFiles.add(filePath); + } + + @override + Future verifySha256(String filePath, String expectedHash) async => true; + + @override + Future replaceFile({ + required String temporaryPath, + required String destinationPath, + }) async { + replacedDestinations.add(destinationPath); + } +} diff --git a/test/features/launcher/data/game_installation_service_test.dart b/test/features/launcher/data/game_installation_service_test.dart new file mode 100644 index 0000000..e808268 --- /dev/null +++ b/test/features/launcher/data/game_installation_service_test.dart @@ -0,0 +1,148 @@ +import 'dart:io'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:moonwell_launcher/config.dart'; +import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart'; +import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart'; +import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart'; + +void main() { + group('GameInstallationService', () { + late Directory rootDirectory; + late GameInstallationService service; + + setUp(() async { + service = GameInstallationService(); + rootDirectory = await Directory.systemTemp.createTemp( + 'moonwell_launcher_', + ); + + await File( + '${rootDirectory.path}${Platform.pathSeparator}Wow.exe', + ).writeAsString('wow'); + await Directory( + '${rootDirectory.path}${Platform.pathSeparator}Data', + ).create(recursive: true); + await File( + '${rootDirectory.path}${Platform.pathSeparator}Data${Platform.pathSeparator}common.MPQ', + ).writeAsString('data'); + + await Directory( + '${rootDirectory.path}${Platform.pathSeparator}Cache', + ).create(recursive: true); + await File( + '${rootDirectory.path}${Platform.pathSeparator}Cache${Platform.pathSeparator}cache.bin', + ).writeAsString('cache'); + + await Directory( + '${rootDirectory.path}${Platform.pathSeparator}Logs', + ).create(recursive: true); + await File( + '${rootDirectory.path}${Platform.pathSeparator}Logs${Platform.pathSeparator}launcher.log', + ).writeAsString('log'); + }); + + tearDown(() async { + if (await rootDirectory.exists()) { + await rootDirectory.delete(recursive: true); + } + }); + + test('scanInstallation ignores excluded directories', () async { + final snapshot = await service.scanInstallation(rootDirectory.path); + final paths = snapshot.files.map((file) => file.path).toList()..sort(); + + expect(paths, ['Data/common.MPQ', 'Wow.exe']); + expect(snapshot.buildHash, computeBuildHash(snapshot.files)); + }); + + test( + 'scanInstallation creates launcher hash cache outside verification set', + () async { + await service.scanInstallation(rootDirectory.path); + + final cacheFile = File(service.getHashCachePath(rootDirectory.path)); + expect(await cacheFile.exists(), isTrue); + + final secondSnapshot = await service.scanInstallation( + rootDirectory.path, + ); + final paths = secondSnapshot.files.map((file) => file.path).toList() + ..sort(); + + expect(paths, ['Data/common.MPQ', 'Wow.exe']); + expect( + await Directory( + '${rootDirectory.path}${Platform.pathSeparator}${Config.launcherMetadataDirectoryName}', + ).exists(), + isTrue, + ); + }, + ); + + test( + 'scanInstallation reuses cached hash when file metadata did not change', + () async { + await service.scanInstallation(rootDirectory.path); + + final cacheFile = File(service.getHashCachePath(rootDirectory.path)); + final cacheJson = + jsonDecode(await cacheFile.readAsString()) as Map; + final entries = (cacheJson['entries'] as Map).cast(); + + entries['Wow.exe'] = { + 'size': 3, + 'modifiedMs': await File( + '${rootDirectory.path}${Platform.pathSeparator}Wow.exe', + ).lastModified().then((value) => value.millisecondsSinceEpoch), + 'sha256': 'cached-wow-hash', + }; + + await cacheFile.writeAsString( + jsonEncode({'version': 1, 'entries': entries}), + flush: true, + ); + + final snapshot = await service.scanInstallation(rootDirectory.path); + final wowFile = snapshot.files.firstWhere( + (file) => file.path == 'Wow.exe', + ); + + expect(wowFile.sha256, 'cached-wow-hash'); + }, + ); + + test('clearCache recreates an empty cache directory', () async { + await service.clearCache(rootDirectory.path); + + final cacheDirectory = Directory( + '${rootDirectory.path}${Platform.pathSeparator}Cache', + ); + + expect(await cacheDirectory.exists(), isTrue); + expect(await cacheDirectory.list().isEmpty, isTrue); + }); + + test('ensureParentDirectoryExists creates nested directories', () async { + final tempPath = + '${rootDirectory.path}${Platform.pathSeparator}Data${Platform.pathSeparator}patches${Platform.pathSeparator}common-2.MPQ.moonwell.part'; + + await service.ensureParentDirectoryExists(tempPath); + + expect( + await Directory( + '${rootDirectory.path}${Platform.pathSeparator}Data${Platform.pathSeparator}patches', + ).exists(), + isTrue, + ); + }); + + test('resolveClientPath rejects path traversal', () { + expect( + () => service.resolveClientPath(rootDirectory.path, '../evil.txt'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/features/launcher/domain/client_hash_entry_test.dart b/test/features/launcher/domain/client_hash_entry_test.dart new file mode 100644 index 0000000..58d63f1 --- /dev/null +++ b/test/features/launcher/domain/client_hash_entry_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart'; +import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart'; + +void main() { + group('client hash entry helpers', () { + test('normalizeClientPath normalizes slashes and strips leading dot', () { + expect(normalizeClientPath(r'.\Data\common.MPQ'), 'Data/common.MPQ'); + expect(normalizeClientPath('./Wow.exe'), 'Wow.exe'); + }); + + test('computeBuildHash is deterministic and order independent', () { + final left = [ + const LocalClientFile( + path: r'.\Data\common.MPQ', + size: 12, + sha256: 'ABCDEF', + ), + const LocalClientFile(path: 'Wow.exe', size: 34, sha256: '123456'), + ]; + final right = [ + const LocalClientFile(path: 'Wow.exe', size: 34, sha256: '123456'), + const LocalClientFile( + path: 'Data/common.MPQ', + size: 12, + sha256: 'abcdef', + ), + ]; + + expect(computeBuildHash(left), computeBuildHash(right)); + }); + }); +}