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

This commit is contained in:
2026-03-22 02:00:47 +04:00
parent 73d8798bb9
commit f0463f8cff
9 changed files with 1239 additions and 97 deletions
+189
View File
@@ -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:
- `<install-root>/.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:
`<normalized-path>:<size>:<sha256-lowercase>`
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 `<install-root>/Wow.exe`
2. launcher clears `<install-root>/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
+3
View File
@@ -4,11 +4,14 @@ final class Config {
); );
static const gameExecutableName = 'Wow.exe'; static const gameExecutableName = 'Wow.exe';
static const cacheDirectoryName = 'Cache'; static const cacheDirectoryName = 'Cache';
static const launcherMetadataDirectoryName = '.moonwell_launcher';
static const hashCacheFileName = 'hash_cache.json';
static const ignoredVerificationDirectories = <String>{ static const ignoredVerificationDirectories = <String>{
'cache', 'cache',
'errors', 'errors',
'logs', 'logs',
'screenshots', 'screenshots',
'wtf', 'wtf',
'.moonwell_launcher',
}; };
} }
@@ -349,6 +349,7 @@ class ClientSyncUseCase
); );
final temporaryPath = '$destinationPath.moonwell.part'; final temporaryPath = '$destinationPath.moonwell.part';
await _installationService.ensureParentDirectoryExists(temporaryPath);
await _installationService.deleteFileIfExists(temporaryPath); await _installationService.deleteFileIfExists(temporaryPath);
await _launcherApiClient.downloadFile( await _launcherApiClient.downloadFile(
@@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:isolate';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
@@ -32,56 +34,118 @@ class GameInstallationService {
return ClientInstallationSnapshot.fromFiles(const []); return ClientInstallationSnapshot.fromFiles(const []);
} }
final pendingFiles = <_PendingFile>[]; final receivePort = ReceivePort();
await _collectFiles( final errorPort = ReceivePort();
rootDirectory, final exitPort = ReceivePort();
rootDirectory.path,
pendingFiles,
isCancelled: isCancelled,
);
pendingFiles.sort(
(left, right) => left.relativePath.compareTo(right.relativePath),
);
final totalBytes = pendingFiles.fold<int>(
0,
(sum, pendingFile) => sum + pendingFile.size,
);
final files = <LocalClientFile>[]; final files = <LocalClientFile>[];
var processedBytes = 0; final completer = Completer<ClientInstallationSnapshot>();
var processedFiles = 0; final hashCachePath = getHashCachePath(installationDir);
final cachedHashes = await loadHashCache(installationDir);
Isolate? isolate;
StreamSubscription<dynamic>? receiveSubscription;
StreamSubscription<dynamic>? errorSubscription;
StreamSubscription<dynamic>? exitSubscription;
Timer? cancellationTimer;
for (final pendingFile in pendingFiles) { Future<void> cleanup({bool killIsolate = true}) async {
await _throwIfCancelled(isCancelled); cancellationTimer?.cancel();
await receiveSubscription?.cancel();
await errorSubscription?.cancel();
await exitSubscription?.cancel();
receivePort.close();
errorPort.close();
exitPort.close();
if (killIsolate) {
isolate?.kill(priority: Isolate.immediate);
}
}
final sha256 = await computeSha256(pendingFile.file.path); receiveSubscription = receivePort.listen((message) async {
processedBytes += pendingFile.size; if (completer.isCompleted || message is! Map) {
processedFiles += 1; return;
}
files.add( final type = message['type'];
LocalClientFile( if (type == 'progress') {
path: pendingFile.relativePath, final file = LocalClientFile(
size: pendingFile.size, path: message['path'] as String,
sha256: sha256, size: (message['size'] as num).toInt(),
), sha256: message['sha256'] as String,
); );
files.add(file);
onProgress?.call( onProgress?.call(
DownloadProgress( DownloadProgress(
speed: 0, speed: 0,
downloaded: processedBytes, downloaded: (message['processedBytes'] as num).toInt(),
total: totalBytes, total: (message['totalBytes'] as num).toInt(),
eta: Duration.zero, eta: Duration.zero,
), ),
pendingFile.relativePath, file.path,
processedFiles, (message['processedFiles'] as num).toInt(),
pendingFiles.length, (message['totalFiles'] as num).toInt(),
); );
return;
} }
return ClientInstallationSnapshot.fromFiles(files); if (type == 'done') {
final snapshot = ClientInstallationSnapshot.fromFiles(files);
completer.complete(snapshot);
await cleanup(killIsolate: false);
}
});
errorSubscription = errorPort.listen((message) async {
if (completer.isCompleted) {
return;
}
completer.completeError(
LauncherSyncException('Failed to scan installation files.'),
);
await cleanup(killIsolate: false);
});
exitSubscription = exitPort.listen((_) async {
if (completer.isCompleted) {
return;
}
completer.completeError(
LauncherSyncException('Hash scan worker exited unexpectedly.'),
);
await cleanup(killIsolate: false);
});
isolate = await Isolate.spawn<Map<String, Object?>>(
_scanInstallationIsolateMain,
<String, Object?>{
'sendPort': receivePort.sendPort,
'installationDir': installationDir,
'hashCachePath': hashCachePath,
'cachedHashes': cachedHashes,
'ignoredDirectories': Config.ignoredVerificationDirectories.toList(),
},
onError: errorPort.sendPort,
onExit: exitPort.sendPort,
);
if (isCancelled != null) {
cancellationTimer = Timer.periodic(const Duration(milliseconds: 200), (
_,
) async {
if (completer.isCompleted) {
return;
}
if (await isCancelled()) {
completer.completeError(const CancelledException());
await cleanup();
}
});
}
return completer.future;
} }
Future<String> computeSha256(String filePath) async { Future<String> computeSha256(String filePath) async {
@@ -109,6 +173,10 @@ class GameInstallationService {
} }
} }
Future<void> ensureParentDirectoryExists(String filePath) {
return File(filePath).parent.create(recursive: true);
}
Future<void> replaceFile({ Future<void> replaceFile({
required String temporaryPath, required String temporaryPath,
required String destinationPath, required String destinationPath,
@@ -163,6 +231,17 @@ class GameInstallationService {
return File(getExecutablePath(installationDir)).exists(); 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) { String getExecutablePath(String installationDir) {
return p.join(installationDir, Config.gameExecutableName); return p.join(installationDir, Config.gameExecutableName);
} }
@@ -183,15 +262,125 @@ class GameInstallationService {
return p.joinAll([installationDir, ...pathSegments]); return p.joinAll([installationDir, ...pathSegments]);
} }
Future<void> _collectFiles( Future<Map<String, Object?>> loadHashCache(String installationDir) async {
final cacheFile = File(getHashCachePath(installationDir));
if (!await cacheFile.exists()) {
return const <String, Object?>{};
}
try {
final rawJson = await cacheFile.readAsString();
final decoded = jsonDecode(rawJson);
if (decoded is! Map) {
return const <String, Object?>{};
}
final entries = decoded['entries'];
if (entries is! Map) {
return const <String, Object?>{};
}
return entries.map((key, value) => MapEntry(key.toString(), value));
} catch (_) {
return const <String, Object?>{};
}
}
}
Future<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
final sendPort = message['sendPort'] as SendPort;
final installationDir = message['installationDir'] as String;
final hashCachePath = message['hashCachePath'] as String;
final cachedHashes = (message['cachedHashes'] as Map<Object?, Object?>).map(
(key, value) => MapEntry(key.toString(), value),
);
final ignoredDirectories = (message['ignoredDirectories'] as List<Object?>)
.whereType<String>()
.map((entry) => entry.toLowerCase())
.toSet();
final rootDirectory = Directory(installationDir);
if (!await rootDirectory.exists()) {
sendPort.send(<String, Object?>{'type': 'done'});
return;
}
final pendingFiles = <_SerializablePendingFile>[];
await _collectFilesInIsolate(
rootDirectory,
rootDirectory.path,
pendingFiles,
ignoredDirectories,
);
pendingFiles.sort(
(left, right) => left.relativePath.compareTo(right.relativePath),
);
final totalBytes = pendingFiles.fold<int>(
0,
(sum, pendingFile) => sum + pendingFile.size,
);
final updatedCacheEntries = <String, Map<String, Object?>>{};
var processedBytes = 0;
var processedFiles = 0;
for (final pendingFile in pendingFiles) {
final digest = await _resolveFileHash(pendingFile, cachedHashes);
processedBytes += pendingFile.size;
processedFiles += 1;
updatedCacheEntries[pendingFile.relativePath] = <String, Object?>{
'size': pendingFile.size,
'modifiedMs': pendingFile.modifiedMs,
'sha256': digest,
};
sendPort.send(<String, Object?>{
'type': 'progress',
'path': pendingFile.relativePath,
'size': pendingFile.size,
'sha256': digest,
'processedBytes': processedBytes,
'totalBytes': totalBytes,
'processedFiles': processedFiles,
'totalFiles': pendingFiles.length,
});
}
await _writeHashCache(hashCachePath, updatedCacheEntries);
sendPort.send(<String, Object?>{'type': 'done'});
}
Future<String> _resolveFileHash(
_SerializablePendingFile pendingFile,
Map<String, Object?> cachedHashes,
) async {
final cachedEntry = cachedHashes[pendingFile.relativePath];
if (cachedEntry is Map) {
final cachedSize = (cachedEntry['size'] as num?)?.toInt();
final cachedModifiedMs = (cachedEntry['modifiedMs'] as num?)?.toInt();
final cachedSha256 = cachedEntry['sha256'] as String?;
if (cachedSize == pendingFile.size &&
cachedModifiedMs == pendingFile.modifiedMs &&
cachedSha256 != null &&
cachedSha256.isNotEmpty) {
return cachedSha256;
}
}
final digest = await sha256.bind(File(pendingFile.path).openRead()).first;
return digest.toString();
}
Future<void> _collectFilesInIsolate(
Directory directory, Directory directory,
String rootPath, String rootPath,
List<_PendingFile> pendingFiles, { List<_SerializablePendingFile> pendingFiles,
FutureOr<bool> Function()? isCancelled, Set<String> ignoredDirectories,
}) async { ) async {
await for (final entity in directory.list(followLinks: false)) { await for (final entity in directory.list(followLinks: false)) {
await _throwIfCancelled(isCancelled);
final relativePath = normalizeClientPath( final relativePath = normalizeClientPath(
p.relative(entity.path, from: rootPath), p.relative(entity.path, from: rootPath),
); );
@@ -200,16 +389,18 @@ class GameInstallationService {
continue; continue;
} }
if (_isExcludedPath(relativePath)) { final segments = p.posix.split(relativePath);
if (segments.isNotEmpty &&
ignoredDirectories.contains(segments.first.toLowerCase())) {
continue; continue;
} }
if (entity is Directory) { if (entity is Directory) {
await _collectFiles( await _collectFilesInIsolate(
entity, entity,
rootPath, rootPath,
pendingFiles, pendingFiles,
isCancelled: isCancelled, ignoredDirectories,
); );
continue; continue;
} }
@@ -220,37 +411,46 @@ class GameInstallationService {
final stat = await entity.stat(); final stat = await entity.stat();
pendingFiles.add( pendingFiles.add(
_PendingFile(file: entity, relativePath: relativePath, size: stat.size), _SerializablePendingFile(
path: entity.path,
relativePath: relativePath,
size: stat.size,
modifiedMs: stat.modified.millisecondsSinceEpoch,
),
); );
} }
}
bool _isExcludedPath(String relativePath) {
final segments = p.posix.split(normalizeClientPath(relativePath));
if (segments.isEmpty) {
return false;
}
return Config.ignoredVerificationDirectories.contains(
segments.first.toLowerCase(),
);
}
Future<void> _throwIfCancelled(FutureOr<bool> Function()? isCancelled) async {
if (isCancelled != null && await isCancelled()) {
throw const CancelledException();
}
}
} }
final class _PendingFile { Future<void> _writeHashCache(
final File file; String hashCachePath,
Map<String, Map<String, Object?>> entries,
) async {
final cacheFile = File(hashCachePath);
await cacheFile.parent.create(recursive: true);
final tempFile = File('$hashCachePath.tmp');
final payload = jsonEncode(<String, Object?>{
'version': 1,
'entries': entries,
});
await tempFile.writeAsString(payload, flush: true);
if (await cacheFile.exists()) {
await cacheFile.delete();
}
await tempFile.rename(hashCachePath);
}
final class _SerializablePendingFile {
final String path;
final String relativePath; final String relativePath;
final int size; final int size;
final int modifiedMs;
const _PendingFile({ const _SerializablePendingFile({
required this.file, required this.path,
required this.relativePath, required this.relativePath,
required this.size, required this.size,
required this.modifiedMs,
}); });
} }
@@ -61,10 +61,17 @@ class LauncherApiClient {
required void Function(int chunkBytes) onChunkReceived, required void Function(int chunkBytes) onChunkReceived,
}) async { }) async {
try { try {
final response = await _dio.getUri<ResponseBody>( final downloadLinkResponse = await _dio.getUri(
_resolveUri('api/launcher/download/${Uri.encodeComponent(file.path)}'), _resolveUri('api/launcher/download/${Uri.encodeComponent(file.path)}'),
options: _authorizedOptions( options: _authorizedOptions(accessToken),
accessToken, );
final downloadUri = _extractDownloadUri(
_coerceMap(downloadLinkResponse.data),
);
final response = await _dio.getUri<ResponseBody>(
downloadUri,
options: Options(
responseType: ResponseType.stream, responseType: ResponseType.stream,
headers: const {HttpHeaders.acceptHeader: 'application/octet-stream'}, headers: const {HttpHeaders.acceptHeader: 'application/octet-stream'},
), ),
@@ -173,4 +180,22 @@ class LauncherApiClient {
return error.message ?? 'Launcher API request failed.'; return error.message ?? 'Launcher API request failed.';
} }
Uri _extractDownloadUri(Map<String, dynamic> payload) {
final rawUrl = payload['url'];
if (rawUrl is! String || rawUrl.trim().isEmpty) {
throw const LauncherApiException(
'Launcher API did not return a valid download URL.',
);
}
final uri = Uri.tryParse(rawUrl);
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
throw const LauncherApiException(
'Launcher API returned an invalid presigned download URL.',
);
}
return uri;
}
} }
+299
View File
@@ -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"
}
]
}
@@ -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<ClientManifestFile> 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<String> downloadedPaths = [];
@override
Future<ClientManifest> fetchManifest(String accessToken) async => _manifest;
@override
Future<void> downloadFile({
required String accessToken,
required ClientManifestFile file,
required String destinationPath,
required FutureOr<bool> Function()? isCancelled,
required void Function(int chunkBytes) onChunkReceived,
}) async {
downloadedPaths.add(file.path);
onChunkReceived(file.size);
}
}
class _FakeGameInstallationService extends GameInstallationService {
_FakeGameInstallationService(List<ClientInstallationSnapshot> snapshots)
: _snapshots = Queue.of(snapshots);
final Queue<ClientInstallationSnapshot> _snapshots;
final List<String> ensuredParentDirectories = [];
final List<String> deletedRelativeFiles = [];
final List<String> deletedFiles = [];
final List<String> replacedDestinations = [];
@override
Future<ClientInstallationSnapshot> scanInstallation(
String installationDir, {
InstallationScanProgressCallback? onProgress,
FutureOr<bool> 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<int>(0, (sum, item) => sum + item.size),
eta: Duration.zero,
),
file.path,
processedFiles,
snapshot.files.length,
);
}
return snapshot;
}
@override
Future<void> deleteRelativeFile(
String installationDir,
String relativePath,
) async {
deletedRelativeFiles.add(relativePath);
}
@override
String resolveClientPath(String installationDir, String relativePath) {
return '$installationDir/$relativePath';
}
@override
Future<void> ensureParentDirectoryExists(String filePath) async {
ensuredParentDirectories.add(filePath);
}
@override
Future<void> deleteFileIfExists(String filePath) async {
deletedFiles.add(filePath);
}
@override
Future<bool> verifySha256(String filePath, String expectedHash) async => true;
@override
Future<void> replaceFile({
required String temporaryPath,
required String destinationPath,
}) async {
replacedDestinations.add(destinationPath);
}
}
@@ -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<String, dynamic>;
final entries = (cacheJson['entries'] as Map).cast<String, dynamic>();
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<LauncherSyncException>()),
);
});
});
}
@@ -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));
});
});
}