загрзука клиента и хэширование
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 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) {
|
Future<void> _writeHashCache(
|
||||||
final segments = p.posix.split(normalizeClientPath(relativePath));
|
String hashCachePath,
|
||||||
if (segments.isEmpty) {
|
Map<String, Map<String, Object?>> entries,
|
||||||
return false;
|
) 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Config.ignoredVerificationDirectories.contains(
|
final class _SerializablePendingFile {
|
||||||
segments.first.toLowerCase(),
|
final String path;
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _throwIfCancelled(FutureOr<bool> Function()? isCancelled) async {
|
|
||||||
if (isCancelled != null && await isCancelled()) {
|
|
||||||
throw const CancelledException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class _PendingFile {
|
|
||||||
final File file;
|
|
||||||
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
@@ -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