логирование

This commit is contained in:
2026-03-22 02:18:40 +04:00
parent f0463f8cff
commit 8ed2b692b6
8 changed files with 652 additions and 47 deletions
+16
View File
@@ -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`
+1
View File
@@ -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,10 +392,12 @@ class ClientSyncUseCase
); );
final temporaryPath = '$destinationPath.moonwell.part'; final temporaryPath = '$destinationPath.moonwell.part';
try {
await _installationService.ensureParentDirectoryExists(temporaryPath); await _installationService.ensureParentDirectoryExists(temporaryPath);
await _installationService.deleteFileIfExists(temporaryPath); await _installationService.deleteFileIfExists(temporaryPath);
await _launcherApiClient.downloadFile( await _launcherApiClient.downloadFile(
installationDir: installationDir,
accessToken: accessToken, accessToken: accessToken,
file: file, file: file,
destinationPath: temporaryPath, destinationPath: temporaryPath,
@@ -363,16 +408,11 @@ class ClientSyncUseCase
}, },
); );
final isValid = await _installationService.verifySha256( await _verifyDownloadedFile(
temporaryPath, installationDir: installationDir,
file.sha256, file: file,
temporaryPath: temporaryPath,
); );
if (!isValid) {
await _installationService.deleteFileIfExists(temporaryPath);
throw LauncherSyncException(
'Downloaded file failed verification: ${file.path}',
);
}
await _installationService.replaceFile( await _installationService.replaceFile(
temporaryPath: temporaryPath, temporaryPath: temporaryPath,
@@ -381,6 +421,23 @@ class ClientSyncUseCase
processedFiles += 1; processedFiles += 1;
emitProgress(); 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(
'Failed to update local file: ${file.path}. '
'See ${_launcherLogService.logHint(installationDir)}.',
);
}
} }
} 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'),