import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:injectable/injectable.dart'; import 'package:moonwell_launcher/config.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_file.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart'; @lazySingleton class LauncherApiClient { LauncherApiClient({LauncherLogService? launcherLogService}) : _launcherLogService = launcherLogService ?? LauncherLogService(), _dio = Dio( BaseOptions( connectTimeout: const Duration(seconds: 30), sendTimeout: const Duration(seconds: 30), receiveTimeout: const Duration(minutes: 30), headers: const {HttpHeaders.acceptHeader: 'application/json'}, ), ); final Dio _dio; final LauncherLogService _launcherLogService; Future login({ required String username, required String password, }) async { final loginUri = _resolveUri('api/launcher/login'); try { final response = await _dio.postUri( loginUri, data: {'username': username, 'password': password}, ); return LauncherSession.fromJson(_coerceMap(response.data)); } on DioException catch (error, stackTrace) { await _launcherLogService.error( 'Launcher login request failed.', details: { 'endpoint': loginUri.toString(), 'statusCode': error.response?.statusCode, 'dioType': error.type.name, 'responseBody': _summarizePayload(error.response?.data), }, error: error, stackTrace: stackTrace, ); throw LauncherApiException(_extractErrorMessage(error)); } } Future fetchManifest(String accessToken) async { final manifestUri = _resolveUri('api/launcher/manifest'); try { final response = await _dio.getUri( manifestUri, options: _authorizedOptions(accessToken), ); return ClientManifest.fromJson(_coerceMap(response.data)); } on DioException catch (error, stackTrace) { await _launcherLogService.error( 'Launcher manifest request failed.', details: { 'endpoint': manifestUri.toString(), 'statusCode': error.response?.statusCode, 'dioType': error.type.name, 'responseBody': _summarizePayload(error.response?.data), }, error: error, stackTrace: stackTrace, ); throw LauncherApiException(_extractErrorMessage(error)); } } Future downloadFile({ required String installationDir, required String accessToken, required ClientManifestFile file, required String destinationPath, required FutureOr Function()? isCancelled, required void Function(int chunkBytes) onChunkReceived, }) async { final downloadLinkUri = _resolveUri( 'api/launcher/download/${Uri.encodeComponent(file.path)}', ); Uri? downloadUri; try { final downloadLinkResponse = await _dio.getUri( downloadLinkUri, options: _authorizedOptions(accessToken), ); downloadUri = _extractDownloadUri(_coerceMap(downloadLinkResponse.data)); } on DioException catch (error, stackTrace) { await _launcherLogService.error( 'Failed to resolve presigned download URL.', installationDir: installationDir, details: { '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: { 'filePath': file.path, 'endpoint': downloadLinkUri.toString(), }, error: error, stackTrace: stackTrace, ); throw LauncherApiException( '${error.message} See ${_launcherLogService.logHint(installationDir)}.', ); } try { final response = await _dio.getUri( downloadUri, options: Options( responseType: ResponseType.stream, headers: const {HttpHeaders.acceptHeader: 'application/octet-stream'}, ), ); final stream = response.data?.stream; if (stream == null) { throw const LauncherApiException( 'Server returned an empty file stream.', ); } final sink = File(destinationPath).openWrite(); try { await for (final chunk in stream) { if (isCancelled != null && await isCancelled()) { throw const CancelledException(); } sink.add(chunk); onChunkReceived(chunk.length); } } finally { await sink.close(); } } on DioException catch (error, stackTrace) { await _launcherLogService.error( 'Failed to download file payload.', installationDir: installationDir, details: { '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: { '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: { '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)}.', ); } } Options _authorizedOptions( String accessToken, { ResponseType? responseType, Map? headers, }) { return Options( responseType: responseType, headers: { HttpHeaders.authorizationHeader: 'Bearer $accessToken', ...?headers, }, ); } Uri _resolveUri(String path) { final rawBaseUrl = Config.launcherApiBaseUrl.trim(); if (rawBaseUrl.isEmpty) { throw const LauncherConfigurationException( 'Set MOONWELL_API_BASE_URL before using the launcher API.', ); } final baseUri = Uri.parse( rawBaseUrl.endsWith('/') ? rawBaseUrl : '$rawBaseUrl/', ); if (!baseUri.hasScheme || baseUri.host.isEmpty) { throw const LauncherConfigurationException( 'MOONWELL_API_BASE_URL must be an absolute URL.', ); } return baseUri.resolve(path); } Map _coerceMap(Object? data) { if (data is Map) { return data; } if (data is Map) { return data.map((key, value) => MapEntry(key.toString(), value)); } throw const LauncherApiException('Unexpected API response payload.'); } String _extractErrorMessage(DioException error) { final data = error.response?.data; if (data is Map) { final message = data['message']; if (message is String && message.trim().isNotEmpty) { return message; } } if (data is Map) { final message = data['message']; if (message is String && message.trim().isNotEmpty) { return message; } } if (data is String && data.trim().isNotEmpty) { return data; } if (error.type == DioExceptionType.connectionTimeout || error.type == DioExceptionType.receiveTimeout || error.type == DioExceptionType.sendTimeout || error.error is SocketException) { return 'Unable to reach the launcher API.'; } 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 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; } }