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