загрзука клиента и хэширование
This commit is contained in:
@@ -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
|
||||
@@ -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 = <String>{
|
||||
'cache',
|
||||
'errors',
|
||||
'logs',
|
||||
'screenshots',
|
||||
'wtf',
|
||||
'.moonwell_launcher',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -349,6 +349,7 @@ class ClientSyncUseCase
|
||||
);
|
||||
final temporaryPath = '$destinationPath.moonwell.part';
|
||||
|
||||
await _installationService.ensureParentDirectoryExists(temporaryPath);
|
||||
await _installationService.deleteFileIfExists(temporaryPath);
|
||||
|
||||
await _launcherApiClient.downloadFile(
|
||||
|
||||
@@ -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<int>(
|
||||
0,
|
||||
(sum, pendingFile) => sum + pendingFile.size,
|
||||
);
|
||||
|
||||
final receivePort = ReceivePort();
|
||||
final errorPort = ReceivePort();
|
||||
final exitPort = ReceivePort();
|
||||
final files = <LocalClientFile>[];
|
||||
var processedBytes = 0;
|
||||
var processedFiles = 0;
|
||||
final completer = Completer<ClientInstallationSnapshot>();
|
||||
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) {
|
||||
await _throwIfCancelled(isCancelled);
|
||||
Future<void> 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);
|
||||
}
|
||||
}
|
||||
|
||||
final sha256 = await computeSha256(pendingFile.file.path);
|
||||
processedBytes += pendingFile.size;
|
||||
processedFiles += 1;
|
||||
receiveSubscription = receivePort.listen((message) async {
|
||||
if (completer.isCompleted || message is! Map) {
|
||||
return;
|
||||
}
|
||||
|
||||
files.add(
|
||||
LocalClientFile(
|
||||
path: pendingFile.relativePath,
|
||||
size: pendingFile.size,
|
||||
sha256: sha256,
|
||||
),
|
||||
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: processedBytes,
|
||||
total: totalBytes,
|
||||
downloaded: (message['processedBytes'] as num).toInt(),
|
||||
total: (message['totalBytes'] as num).toInt(),
|
||||
eta: Duration.zero,
|
||||
),
|
||||
pendingFile.relativePath,
|
||||
processedFiles,
|
||||
pendingFiles.length,
|
||||
file.path,
|
||||
(message['processedFiles'] as num).toInt(),
|
||||
(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 {
|
||||
@@ -109,6 +173,10 @@ class GameInstallationService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> ensureParentDirectoryExists(String filePath) {
|
||||
return File(filePath).parent.create(recursive: true);
|
||||
}
|
||||
|
||||
Future<void> 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,15 +262,125 @@ class GameInstallationService {
|
||||
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,
|
||||
String rootPath,
|
||||
List<_PendingFile> pendingFiles, {
|
||||
FutureOr<bool> Function()? isCancelled,
|
||||
}) async {
|
||||
List<_SerializablePendingFile> pendingFiles,
|
||||
Set<String> ignoredDirectories,
|
||||
) async {
|
||||
await for (final entity in directory.list(followLinks: false)) {
|
||||
await _throwIfCancelled(isCancelled);
|
||||
|
||||
final relativePath = normalizeClientPath(
|
||||
p.relative(entity.path, from: rootPath),
|
||||
);
|
||||
@@ -200,16 +389,18 @@ class GameInstallationService {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_isExcludedPath(relativePath)) {
|
||||
final segments = p.posix.split(relativePath);
|
||||
if (segments.isNotEmpty &&
|
||||
ignoredDirectories.contains(segments.first.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entity is Directory) {
|
||||
await _collectFiles(
|
||||
await _collectFilesInIsolate(
|
||||
entity,
|
||||
rootPath,
|
||||
pendingFiles,
|
||||
isCancelled: isCancelled,
|
||||
ignoredDirectories,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -220,37 +411,46 @@ class GameInstallationService {
|
||||
|
||||
final stat = await entity.stat();
|
||||
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 {
|
||||
final File file;
|
||||
Future<void> _writeHashCache(
|
||||
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 int size;
|
||||
final int modifiedMs;
|
||||
|
||||
const _PendingFile({
|
||||
required this.file,
|
||||
const _SerializablePendingFile({
|
||||
required this.path,
|
||||
required this.relativePath,
|
||||
required this.size,
|
||||
required this.modifiedMs,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,10 +61,17 @@ class LauncherApiClient {
|
||||
required void Function(int chunkBytes) onChunkReceived,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.getUri<ResponseBody>(
|
||||
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<ResponseBody>(
|
||||
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<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
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user