базовая логика скачивания обновлений

This commit is contained in:
2026-03-22 01:04:16 +04:00
parent 4b1cecbefd
commit 73d8798bb9
39 changed files with 2372 additions and 1017 deletions
@@ -1,106 +0,0 @@
import 'dart:async';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_request.dart';
import 'package:moonwell_launcher/features/downloader/use_cases/download_object_use_case.dart';
typedef DownloadId = String;
class _Session {
_Session({required this.id, required this.request, required this.useCase})
: progressController = StreamController<DownloadProgress>.broadcast();
final DownloadId id;
final DownloadRequest request;
final DownloadObjectUseCase useCase;
final StreamController<DownloadProgress> progressController;
StreamSubscription<DownloadProgress>? sub;
bool paused = false;
bool cancelled = false;
Stream<DownloadProgress> get stream => progressController.stream;
Future<void> start() async {
cancelled = false;
paused = false;
sub = useCase
.call(
DownloadObjectUseCaseInput(
request: request,
isCancelled: () async => cancelled || paused,
),
)
.listen(
progressController.add,
onError: progressController.addError,
onDone: () => progressController.close(),
);
}
Future<void> pause() async {
paused = true;
await sub?.cancel();
sub = null;
}
Future<void> resume() async {
if (cancelled) return;
paused = false;
await start(); // will resume from file size
}
Future<void> cancel() async {
cancelled = true;
await sub?.cancel();
sub = null;
if (!progressController.isClosed) {
progressController.addError(const CancelledException());
await progressController.close();
}
}
}
@lazySingleton
class DownloadManager {
DownloadManager(this._useCase);
final DownloadObjectUseCase _useCase;
final Map<DownloadId, _Session> _sessions = {};
/// Start or replace a session with [id].
Stream<DownloadProgress> start({
required DownloadId id,
required DownloadRequest request,
}) {
// dispose old session if exists
_sessions[id]?.cancel();
final s = _Session(id: id, request: request, useCase: _useCase);
_sessions[id] = s;
unawaited(s.start());
return s.stream;
}
Stream<DownloadProgress>? progress(DownloadId id) => _sessions[id]?.stream;
Future<void> pause(DownloadId id) => _sessions[id]?.pause() ?? Future.value();
Future<void> resume(DownloadId id) =>
_sessions[id]?.resume() ?? Future.value();
Future<void> cancel(DownloadId id) async {
await _sessions[id]?.cancel();
_sessions.remove(id);
}
bool isActive(DownloadId id) => _sessions.containsKey(id);
Iterable<DownloadId> activeIds() => _sessions.keys;
/// Cancel and clear all sessions (e.g., on sign-out).
Future<void> cancelAll() async {
for (final s in _sessions.values) {
await s.cancel();
}
_sessions.clear();
}
}
@@ -1,64 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:crypto/crypto.dart' as c;
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/features/downloader/domain/repositories/file_system.dart';
@LazySingleton(as: FileSystem)
class IoFileSystem implements FileSystem {
@override
Future<void> ensureParentExists(String path) =>
File(path).parent.create(recursive: true);
@override
Future<bool> exists(String path) => File(path).exists();
@override
Future<int> sizeOf(String path) async => (await File(path).stat()).size;
@override
Future<void> truncate(String path) async =>
File(path).writeAsBytes(const [], flush: true);
@override
Future<void> createEmpty(String path) => File(path).create(recursive: true);
@override
Future<StreamSink<List<int>>> openAppend(String path) async =>
File(path).openWrite(mode: FileMode.append);
@override
Future<bool> verifyFile(
String path, {
required String expected,
required String algo,
}) async {
final file = File(path);
if (!await file.exists()) return false;
final digestHex = await _computeDigestHex(file, algo);
return _constantTimeEquals(digestHex.toLowerCase(), expected.toLowerCase());
}
Future<String> _computeDigestHex(File file, String algo) async {
final hash = switch (algo.toLowerCase()) {
'md5' => c.md5,
'sha256' => c.sha256,
_ => throw ArgumentError('Unsupported checksum algo: $algo'),
};
// Stream the file into the hash converter; no extra buffers.
final digest = await hash.bind(file.openRead()).first; // -> c.Digest
return digest.toString(); // lowercase hex
}
bool _constantTimeEquals(String a, String b) {
if (a.length != b.length) return false;
var result = 0;
for (var i = 0; i < a.length; i++) {
result |= a.codeUnitAt(i) ^ b.codeUnitAt(i);
}
return result == 0;
}
}
@@ -1,30 +0,0 @@
import 'package:injectable/injectable.dart';
import 'package:minio/minio.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/remote_object_meta.dart';
import 'package:moonwell_launcher/features/downloader/domain/repositories/storage_reader.dart';
/// MinIO storage reader implementation.
@LazySingleton(as: StorageReader)
class MinioStorageReader implements StorageReader {
final Minio _client;
MinioStorageReader(this._client);
@override
Future<Stream<List<int>>> readFromOffset({
required String bucket,
required String key,
required int startOffset,
}) {
return _client.getPartialObject(bucket, key, startOffset);
}
@override
Future<RemoteObjectMeta> stat({
required String bucket,
required String key,
}) async {
final s = await _client.statObject(bucket, key);
return RemoteObjectMeta(size: s.size ?? 0, eTag: s.etag ?? '');
}
}
@@ -1,7 +0,0 @@
/// Represents a request to download an object from a storage bucket.
class DownloadRequest {
/// Local file path to save the downloaded object
final String destinationPath;
const DownloadRequest({required this.destinationPath});
}
@@ -1,17 +0,0 @@
/// Represents the result of a download operation.
class DownloadResult {
/// Local file path where the downloaded object is stored.
final String path;
/// Number of bytes downloaded.
final int bytes;
/// Entity tag (ETag) of the downloaded object.
final String eTag;
const DownloadResult({
required this.path,
required this.bytes,
required this.eTag,
});
}
@@ -1,10 +0,0 @@
/// Represents metadata for a remote object in a storage bucket.
class RemoteObjectMeta {
/// Size of the object in bytes.
final int size;
/// Entity tag (ETag) of the object.
final String eTag;
const RemoteObjectMeta({required this.size, required this.eTag});
}
@@ -1,29 +0,0 @@
import 'dart:async';
/// Interface for interacting with the file system.
abstract interface class FileSystem {
/// Ensures that the parent directory of the given path exists.
Future<void> ensureParentExists(String path);
/// Checks if a file or directory exists at the given path.
Future<bool> exists(String path);
/// Returns the size of the file at the given path.
Future<int> sizeOf(String path);
/// Truncates the file at the given path to zero length.
Future<void> truncate(String path);
/// Creates an empty file at the given path.
Future<void> createEmpty(String path);
/// Opens a stream sink for appending data to the file at the given path.
Future<StreamSink<List<int>>> openAppend(String path);
/// Verifies the integrity of a file at the given path.
Future<bool> verifyFile(
String path, {
required String expected,
required String algo,
});
}
@@ -1,14 +0,0 @@
import 'package:moonwell_launcher/features/downloader/domain/entities/remote_object_meta.dart';
/// Interface for reading data from storage.
abstract interface class StorageReader {
/// Retrieves metadata about a remote object.
Future<RemoteObjectMeta> stat({required String bucket, required String key});
/// Reads data from a remote object starting at the specified offset.
Future<Stream<List<int>>> readFromOffset({
required String bucket,
required String key,
required int startOffset,
});
}
@@ -1,160 +0,0 @@
import 'dart:async';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/config.dart';
import 'package:moonwell_launcher/core/use_case.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_request.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_result.dart';
import 'package:moonwell_launcher/features/downloader/domain/repositories/file_system.dart';
import 'package:moonwell_launcher/features/downloader/domain/repositories/storage_reader.dart';
final class DownloadObjectUseCaseInput {
final DownloadRequest request;
final FutureOr<bool> Function()? isCancelled;
final void Function(DownloadResult result)? onComplete;
const DownloadObjectUseCaseInput({
required this.request,
this.isCancelled,
this.onComplete,
});
}
@lazySingleton
class DownloadObjectUseCase
implements StreamUseCase<DownloadObjectUseCaseInput, DownloadProgress> {
final StorageReader _storage;
final FileSystem _fileSystem;
const DownloadObjectUseCase({
required StorageReader storage,
required FileSystem fileSystem,
}) : _storage = storage,
_fileSystem = fileSystem;
@override
Stream<DownloadProgress> call(DownloadObjectUseCaseInput input) async* {
final request = input.request;
final meta = await _storage.stat(
bucket: Config.bucketName,
key: Config.objectName,
);
final total = meta.size;
final pathToFile = '${request.destinationPath}${Config.objectName}';
await _fileSystem.ensureParentExists(pathToFile);
int offset = 0;
if (await _fileSystem.exists(pathToFile)) {
offset = await _fileSystem.sizeOf(pathToFile);
if (offset > total) {
await _fileSystem.truncate(pathToFile);
offset = 0;
}
if (offset == total) {
input.onComplete?.call(
DownloadResult(path: pathToFile, bytes: offset, eTag: meta.eTag),
);
return;
}
} else {
await _fileSystem.createEmpty(pathToFile);
}
final objectStream = await _storage.readFromOffset(
bucket: Config.bucketName,
key: Config.objectName,
startOffset: offset,
);
final sink = await _fileSystem.openAppend(pathToFile);
final controller = StreamController<DownloadProgress>();
final sw = Stopwatch()..start();
int lastBytes = 0;
int downloaded = offset;
Timer? ticker;
void tick() {
final elapsedMs = sw.elapsedMilliseconds;
final bps = elapsedMs == 0 ? 0 : (lastBytes * 1000) ~/ elapsedMs;
final remaining = (total - downloaded).clamp(0, total);
final eta = bps > 0 ? Duration(seconds: remaining ~/ bps) : Duration.zero;
controller.add(
DownloadProgress(
downloaded: downloaded,
total: total,
speed: bps,
eta: eta,
),
);
lastBytes = 0;
sw
..reset()
..start();
}
ticker = Timer.periodic(const Duration(seconds: 1), (_) => tick());
late StreamSubscription<List<int>> sub;
controller.onCancel = () async {
await sub.cancel();
await sink.close();
ticker?.cancel();
};
sub = objectStream.listen(
(chunk) async {
if (input.isCancelled != null && await input.isCancelled!.call()) {
ticker?.cancel();
await sub.cancel();
await sink.close();
controller.addError(const CancelledException());
await controller.close();
return;
}
sink.add(chunk);
final n = chunk.length;
downloaded += n;
lastBytes += n;
},
onError: (e, st) async {
ticker?.cancel();
await sink.close();
await controller.close();
throw _mapError(e);
},
onDone: () async {
ticker?.cancel();
tick(); // final snapshot
await sink.close();
await controller.close();
input.onComplete?.call(
DownloadResult(path: pathToFile, bytes: downloaded, eTag: meta.eTag),
);
},
cancelOnError: true,
);
yield* controller.stream;
}
DownloadException _mapError(Object e) {
final s = e.toString();
if (s.contains('SocketException') || s.contains('HandshakeException')) {
return NetworkException(s);
}
if (s.contains('Minio')) return RemoteException(s);
return IoException(s);
}
}
@@ -0,0 +1,453 @@
import 'dart:async';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/core/use_case.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.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/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/launcher_exception.dart';
final class ClientSyncRequest {
final String installationDir;
final String accessToken;
final ClientManifest? manifest;
const ClientSyncRequest({
required this.installationDir,
required this.accessToken,
this.manifest,
});
}
final class ClientSyncUseCaseInput {
final ClientSyncRequest request;
final FutureOr<bool> Function()? isCancelled;
const ClientSyncUseCaseInput({required this.request, this.isCancelled});
}
@lazySingleton
class ClientSyncUseCase
implements StreamUseCase<ClientSyncUseCaseInput, ClientSyncStatus> {
ClientSyncUseCase({
required LauncherApiClient launcherApiClient,
required GameInstallationService installationService,
}) : _launcherApiClient = launcherApiClient,
_installationService = installationService;
final LauncherApiClient _launcherApiClient;
final GameInstallationService _installationService;
@override
Stream<ClientSyncStatus> call(ClientSyncUseCaseInput input) {
final controller = StreamController<ClientSyncStatus>();
unawaited(_run(input, controller));
return controller.stream;
}
Future<void> _run(
ClientSyncUseCaseInput input,
StreamController<ClientSyncStatus> controller,
) async {
try {
await _throwIfCancelled(input.isCancelled);
_emit(
controller,
ClientSyncStatus(
stage: ClientSyncStage.fetchingManifest,
progress: const DownloadProgress.initial(),
message: 'Loading client manifest...',
currentPath: null,
localBuildHash: null,
remoteBuildHash: null,
processedFiles: 0,
totalFiles: 0,
),
);
final manifest =
input.request.manifest ??
await _launcherApiClient.fetchManifest(input.request.accessToken);
final remoteBuildHash = manifest.buildHash;
final localSnapshot = await _installationService.scanInstallation(
input.request.installationDir,
isCancelled: input.isCancelled,
onProgress: (progress, currentPath, processedFiles, totalFiles) {
_emit(
controller,
ClientSyncStatus(
stage: ClientSyncStage.scanningLocalFiles,
progress: progress,
message: 'Hashing local files...',
currentPath: currentPath,
localBuildHash: null,
remoteBuildHash: remoteBuildHash,
processedFiles: processedFiles,
totalFiles: totalFiles,
),
);
},
);
await _throwIfCancelled(input.isCancelled);
final filesToDownload = _resolveFilesToDownload(manifest, localSnapshot);
final staleFiles = _resolveStaleFiles(manifest, localSnapshot);
_emit(
controller,
ClientSyncStatus(
stage: ClientSyncStage.comparingFiles,
progress: DownloadProgress(
speed: 0,
downloaded: localSnapshot.files.length,
total: manifest.files.length,
eta: Duration.zero,
),
message: _buildComparisonMessage(
localSnapshot: localSnapshot,
remoteBuildHash: remoteBuildHash,
filesToDownload: filesToDownload.length,
staleFiles: staleFiles.length,
),
currentPath: null,
localBuildHash: localSnapshot.buildHash,
remoteBuildHash: remoteBuildHash,
processedFiles: localSnapshot.files.length,
totalFiles: manifest.files.length,
),
);
if (localSnapshot.buildHash == remoteBuildHash &&
filesToDownload.isEmpty &&
staleFiles.isEmpty) {
_emit(
controller,
ClientSyncStatus(
stage: ClientSyncStage.completed,
progress: _fullProgress(manifest),
message: 'Client is up to date.',
currentPath: null,
localBuildHash: localSnapshot.buildHash,
remoteBuildHash: remoteBuildHash,
processedFiles: manifest.files.length,
totalFiles: manifest.files.length,
),
);
return;
}
if (staleFiles.isNotEmpty) {
await _removeStaleFiles(
controller: controller,
installationDir: input.request.installationDir,
staleFiles: staleFiles,
remoteBuildHash: remoteBuildHash,
isCancelled: input.isCancelled,
);
}
if (filesToDownload.isNotEmpty) {
await _downloadChangedFiles(
controller: controller,
manifest: manifest,
accessToken: input.request.accessToken,
installationDir: input.request.installationDir,
filesToDownload: filesToDownload,
remoteBuildHash: remoteBuildHash,
isCancelled: input.isCancelled,
);
}
final verifiedSnapshot = await _installationService.scanInstallation(
input.request.installationDir,
isCancelled: input.isCancelled,
onProgress: (progress, currentPath, processedFiles, totalFiles) {
_emit(
controller,
ClientSyncStatus(
stage: ClientSyncStage.verifyingInstallation,
progress: progress,
message: 'Verifying updated client...',
currentPath: currentPath,
localBuildHash: null,
remoteBuildHash: remoteBuildHash,
processedFiles: processedFiles,
totalFiles: totalFiles,
),
);
},
);
await _throwIfCancelled(input.isCancelled);
_ensureVerificationPassed(
manifest: manifest,
snapshot: verifiedSnapshot,
remoteBuildHash: remoteBuildHash,
);
_emit(
controller,
ClientSyncStatus(
stage: ClientSyncStage.completed,
progress: _fullProgress(manifest),
message: 'Client is ready to play.',
currentPath: null,
localBuildHash: verifiedSnapshot.buildHash,
remoteBuildHash: remoteBuildHash,
processedFiles: manifest.files.length,
totalFiles: manifest.files.length,
),
);
} catch (error, stackTrace) {
if (!controller.isClosed) {
controller.addError(error, stackTrace);
}
} finally {
await controller.close();
}
}
List<ClientManifestFile> _resolveFilesToDownload(
ClientManifest manifest,
ClientInstallationSnapshot snapshot,
) {
final localFilesByPath = snapshot.filesByPath;
return manifest.files.where((remoteFile) {
final localFile = localFilesByPath[remoteFile.path];
return localFile == null ||
localFile.sha256 != remoteFile.sha256 ||
localFile.size != remoteFile.size;
}).toList();
}
List<String> _resolveStaleFiles(
ClientManifest manifest,
ClientInstallationSnapshot snapshot,
) {
final remoteFiles = manifest.filesByPath;
return snapshot.files
.where((localFile) => !remoteFiles.containsKey(localFile.path))
.map((file) => file.path)
.toList();
}
Future<void> _removeStaleFiles({
required StreamController<ClientSyncStatus> controller,
required String installationDir,
required List<String> staleFiles,
required String remoteBuildHash,
required FutureOr<bool> Function()? isCancelled,
}) async {
var removedFiles = 0;
for (final relativePath in staleFiles) {
await _throwIfCancelled(isCancelled);
await _installationService.deleteRelativeFile(
installationDir,
relativePath,
);
removedFiles += 1;
_emit(
controller,
ClientSyncStatus(
stage: ClientSyncStage.removingStaleFiles,
progress: DownloadProgress(
speed: 0,
downloaded: removedFiles,
total: staleFiles.length,
eta: Duration.zero,
),
message: 'Removing obsolete files...',
currentPath: relativePath,
localBuildHash: null,
remoteBuildHash: remoteBuildHash,
processedFiles: removedFiles,
totalFiles: staleFiles.length,
),
);
}
}
Future<void> _downloadChangedFiles({
required StreamController<ClientSyncStatus> controller,
required ClientManifest manifest,
required String accessToken,
required String installationDir,
required List<ClientManifestFile> filesToDownload,
required String remoteBuildHash,
required FutureOr<bool> Function()? isCancelled,
}) async {
final totalBytes = filesToDownload.fold<int>(
0,
(sum, file) => sum + file.size,
);
final stopwatch = Stopwatch()..start();
var downloadedBytes = 0;
var bytesSinceLastTick = 0;
var processedFiles = 0;
String? currentPath;
void emitProgress() {
final elapsedMs = stopwatch.elapsedMilliseconds;
final speed = elapsedMs == 0
? 0
: (bytesSinceLastTick * 1000) ~/ elapsedMs;
final remainingBytes = totalBytes - downloadedBytes;
final eta = speed > 0
? Duration(seconds: remainingBytes ~/ speed)
: Duration.zero;
_emit(
controller,
ClientSyncStatus(
stage: ClientSyncStage.downloadingFiles,
progress: DownloadProgress(
speed: speed,
downloaded: downloadedBytes,
total: totalBytes,
eta: eta,
),
message: 'Downloading changed files...',
currentPath: currentPath,
localBuildHash: null,
remoteBuildHash: remoteBuildHash,
processedFiles: processedFiles,
totalFiles: filesToDownload.length,
),
);
bytesSinceLastTick = 0;
stopwatch
..reset()
..start();
}
final ticker = Timer.periodic(
const Duration(seconds: 1),
(_) => emitProgress(),
);
try {
for (final file in filesToDownload) {
await _throwIfCancelled(isCancelled);
currentPath = file.path;
final destinationPath = _installationService.resolveClientPath(
installationDir,
file.path,
);
final temporaryPath = '$destinationPath.moonwell.part';
await _installationService.deleteFileIfExists(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);
throw LauncherSyncException(
'Downloaded file failed verification: ${file.path}',
);
}
await _installationService.replaceFile(
temporaryPath: temporaryPath,
destinationPath: destinationPath,
);
processedFiles += 1;
emitProgress();
}
} finally {
ticker.cancel();
}
}
void _ensureVerificationPassed({
required ClientManifest manifest,
required ClientInstallationSnapshot snapshot,
required String remoteBuildHash,
}) {
final localFilesByPath = snapshot.filesByPath;
final mismatchedFiles = manifest.files.where((remoteFile) {
final localFile = localFilesByPath[remoteFile.path];
return localFile == null ||
localFile.sha256 != remoteFile.sha256 ||
localFile.size != remoteFile.size;
}).toList();
final extraFiles = snapshot.files
.where((localFile) => !manifest.filesByPath.containsKey(localFile.path))
.toList();
if (snapshot.buildHash != remoteBuildHash ||
mismatchedFiles.isNotEmpty ||
extraFiles.isNotEmpty) {
throw LauncherSyncException('Client verification failed after update.');
}
}
String _buildComparisonMessage({
required ClientInstallationSnapshot localSnapshot,
required String remoteBuildHash,
required int filesToDownload,
required int staleFiles,
}) {
if (localSnapshot.buildHash == remoteBuildHash &&
filesToDownload == 0 &&
staleFiles == 0) {
return 'Local build matches the server manifest.';
}
return 'Update required: $filesToDownload file(s) to download, '
'$staleFiles file(s) to remove.';
}
DownloadProgress _fullProgress(ClientManifest manifest) {
return DownloadProgress(
speed: 0,
downloaded: manifest.totalSize,
total: manifest.totalSize,
eta: Duration.zero,
);
}
void _emit(
StreamController<ClientSyncStatus> controller,
ClientSyncStatus status,
) {
if (!controller.isClosed) {
controller.add(status);
}
}
Future<void> _throwIfCancelled(FutureOr<bool> Function()? isCancelled) async {
if (isCancelled != null && await isCancelled()) {
throw const CancelledException();
}
}
}
@@ -0,0 +1,256 @@
import 'dart:async';
import 'dart:io';
import 'package:crypto/crypto.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/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.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/client_hash_entry.dart';
import 'package:path/path.dart' as p;
typedef InstallationScanProgressCallback =
void Function(
DownloadProgress progress,
String currentPath,
int processedFiles,
int totalFiles,
);
@lazySingleton
class GameInstallationService {
Future<ClientInstallationSnapshot> scanInstallation(
String installationDir, {
InstallationScanProgressCallback? onProgress,
FutureOr<bool> Function()? isCancelled,
}) async {
final rootDirectory = Directory(installationDir);
if (!await rootDirectory.exists()) {
return ClientInstallationSnapshot.fromFiles(const []);
}
final pendingFiles = <_PendingFile>[];
await _collectFiles(
rootDirectory,
rootDirectory.path,
pendingFiles,
isCancelled: isCancelled,
);
pendingFiles.sort(
(left, right) => left.relativePath.compareTo(right.relativePath),
);
final totalBytes = pendingFiles.fold<int>(
0,
(sum, pendingFile) => sum + pendingFile.size,
);
final files = <LocalClientFile>[];
var processedBytes = 0;
var processedFiles = 0;
for (final pendingFile in pendingFiles) {
await _throwIfCancelled(isCancelled);
final sha256 = await computeSha256(pendingFile.file.path);
processedBytes += pendingFile.size;
processedFiles += 1;
files.add(
LocalClientFile(
path: pendingFile.relativePath,
size: pendingFile.size,
sha256: sha256,
),
);
onProgress?.call(
DownloadProgress(
speed: 0,
downloaded: processedBytes,
total: totalBytes,
eta: Duration.zero,
),
pendingFile.relativePath,
processedFiles,
pendingFiles.length,
);
}
return ClientInstallationSnapshot.fromFiles(files);
}
Future<String> computeSha256(String filePath) async {
final digest = await sha256.bind(File(filePath).openRead()).first;
return digest.toString();
}
Future<bool> verifySha256(String filePath, String expectedHash) async {
final file = File(filePath);
if (!await file.exists()) {
return false;
}
final actualHash = await computeSha256(filePath);
return actualHash.toLowerCase() == expectedHash.toLowerCase();
}
Future<void> deleteRelativeFile(
String installationDir,
String relativePath,
) async {
final file = File(resolveClientPath(installationDir, relativePath));
if (await file.exists()) {
await file.delete();
}
}
Future<void> replaceFile({
required String temporaryPath,
required String destinationPath,
}) async {
final destinationFile = File(destinationPath);
await destinationFile.parent.create(recursive: true);
if (await destinationFile.exists()) {
await destinationFile.delete();
}
await File(temporaryPath).rename(destinationPath);
}
Future<void> deleteFileIfExists(String filePath) async {
final file = File(filePath);
if (await file.exists()) {
await file.delete();
}
}
Future<void> clearCache(String installationDir) async {
final cacheDirectory = Directory(
p.join(installationDir, Config.cacheDirectoryName),
);
if (await cacheDirectory.exists()) {
await cacheDirectory.delete(recursive: true);
}
await cacheDirectory.create(recursive: true);
}
Future<void> launchGame(String installationDir) async {
final executablePath = getExecutablePath(installationDir);
final executable = File(executablePath);
if (!await executable.exists()) {
throw const LauncherSyncException(
'Wow.exe was not found in the selected folder.',
);
}
await Process.start(
executablePath,
const <String>[],
workingDirectory: installationDir,
mode: ProcessStartMode.detached,
);
}
Future<bool> hasClientExecutable(String installationDir) async {
return File(getExecutablePath(installationDir)).exists();
}
String getExecutablePath(String installationDir) {
return p.join(installationDir, Config.gameExecutableName);
}
String resolveClientPath(String installationDir, String relativePath) {
final normalizedPath = normalizeClientPath(relativePath);
final pathSegments = p.posix
.split(normalizedPath)
.where((segment) => segment.isNotEmpty && segment != '.')
.toList();
if (pathSegments.isEmpty ||
pathSegments.any((segment) => segment == '..') ||
p.posix.isAbsolute(normalizedPath)) {
throw LauncherSyncException('Invalid client file path: $relativePath');
}
return p.joinAll([installationDir, ...pathSegments]);
}
Future<void> _collectFiles(
Directory directory,
String rootPath,
List<_PendingFile> pendingFiles, {
FutureOr<bool> Function()? isCancelled,
}) async {
await for (final entity in directory.list(followLinks: false)) {
await _throwIfCancelled(isCancelled);
final relativePath = normalizeClientPath(
p.relative(entity.path, from: rootPath),
);
if (relativePath.isEmpty || relativePath == '.') {
continue;
}
if (_isExcludedPath(relativePath)) {
continue;
}
if (entity is Directory) {
await _collectFiles(
entity,
rootPath,
pendingFiles,
isCancelled: isCancelled,
);
continue;
}
if (entity is! File) {
continue;
}
final stat = await entity.stat();
pendingFiles.add(
_PendingFile(file: entity, relativePath: relativePath, size: stat.size),
);
}
}
bool _isExcludedPath(String relativePath) {
final segments = p.posix.split(normalizeClientPath(relativePath));
if (segments.isEmpty) {
return false;
}
return Config.ignoredVerificationDirectories.contains(
segments.first.toLowerCase(),
);
}
Future<void> _throwIfCancelled(FutureOr<bool> Function()? isCancelled) async {
if (isCancelled != null && await isCancelled()) {
throw const CancelledException();
}
}
}
final class _PendingFile {
final File file;
final String relativePath;
final int size;
const _PendingFile({
required this.file,
required this.relativePath,
required this.size,
});
}
@@ -0,0 +1,176 @@
import 'dart:async';
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/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()
: _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;
Future<LauncherSession> login({
required String username,
required String password,
}) async {
try {
final response = await _dio.postUri(
_resolveUri('api/launcher/login'),
data: {'username': username, 'password': password},
);
return LauncherSession.fromJson(_coerceMap(response.data));
} on DioException catch (error) {
throw LauncherApiException(_extractErrorMessage(error));
}
}
Future<ClientManifest> fetchManifest(String accessToken) async {
try {
final response = await _dio.getUri(
_resolveUri('api/launcher/manifest'),
options: _authorizedOptions(accessToken),
);
return ClientManifest.fromJson(_coerceMap(response.data));
} on DioException catch (error) {
throw LauncherApiException(_extractErrorMessage(error));
}
}
Future<void> downloadFile({
required String accessToken,
required ClientManifestFile file,
required String destinationPath,
required FutureOr<bool> Function()? isCancelled,
required void Function(int chunkBytes) onChunkReceived,
}) async {
try {
final response = await _dio.getUri<ResponseBody>(
_resolveUri('api/launcher/download/${Uri.encodeComponent(file.path)}'),
options: _authorizedOptions(
accessToken,
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) {
throw LauncherApiException(_extractErrorMessage(error));
}
}
Options _authorizedOptions(
String accessToken, {
ResponseType? responseType,
Map<String, Object?>? 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<String, dynamic> _coerceMap(Object? data) {
if (data is Map<String, dynamic>) {
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<String, dynamic>) {
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.';
}
}
@@ -0,0 +1,30 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:path/path.dart' as p;
abstract interface class ClientHashEntry {
String get path;
int get size;
String get sha256;
}
String normalizeClientPath(String rawPath) {
final sanitized = rawPath.replaceAll('\\', '/').trim();
final normalized = p.posix.normalize(sanitized);
return normalized.startsWith('./') ? normalized.substring(2) : normalized;
}
String computeBuildHash(Iterable<ClientHashEntry> entries) {
final canonicalEntries =
entries
.map(
(entry) =>
'${normalizeClientPath(entry.path)}:${entry.size}:${entry.sha256.toLowerCase()}',
)
.toList()
..sort();
final payload = canonicalEntries.join('\n');
return sha256.convert(utf8.encode(payload)).toString();
}
@@ -0,0 +1,23 @@
import 'client_hash_entry.dart';
import 'local_client_file.dart';
final class ClientInstallationSnapshot {
final List<LocalClientFile> files;
final String buildHash;
const ClientInstallationSnapshot({
required this.files,
required this.buildHash,
});
factory ClientInstallationSnapshot.fromFiles(List<LocalClientFile> files) {
return ClientInstallationSnapshot(
files: files,
buildHash: computeBuildHash(files),
);
}
Map<String, LocalClientFile> get filesByPath => {
for (final file in files) normalizeClientPath(file.path): file,
};
}
@@ -0,0 +1,26 @@
import 'client_hash_entry.dart';
import 'client_manifest_file.dart';
final class ClientManifest {
final List<ClientManifestFile> files;
final String buildHash;
const ClientManifest({required this.files, required this.buildHash});
factory ClientManifest.fromJson(Map<String, dynamic> json) {
final files =
((json['files'] as List<dynamic>? ?? const <dynamic>[])
.whereType<Map<String, dynamic>>()
.map(ClientManifestFile.fromJson)
.toList())
.cast<ClientManifestFile>();
return ClientManifest(files: files, buildHash: computeBuildHash(files));
}
Map<String, ClientManifestFile> get filesByPath => {
for (final file in files) normalizeClientPath(file.path): file,
};
int get totalSize => files.fold(0, (sum, file) => sum + file.size);
}
@@ -0,0 +1,26 @@
import 'client_hash_entry.dart';
final class ClientManifestFile implements ClientHashEntry {
@override
final String path;
@override
final int size;
@override
final String sha256;
const ClientManifestFile({
required this.path,
required this.size,
required this.sha256,
});
factory ClientManifestFile.fromJson(Map<String, dynamic> json) {
return ClientManifestFile(
path: normalizeClientPath(json['path'] as String? ?? ''),
size: (json['size'] as num? ?? 0).toInt(),
sha256: (json['sha256'] as String? ?? '').toLowerCase(),
);
}
}
@@ -0,0 +1,33 @@
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
enum ClientSyncStage {
fetchingManifest,
scanningLocalFiles,
comparingFiles,
removingStaleFiles,
downloadingFiles,
verifyingInstallation,
completed,
}
final class ClientSyncStatus {
final ClientSyncStage stage;
final DownloadProgress progress;
final String message;
final String? currentPath;
final String? localBuildHash;
final String? remoteBuildHash;
final int processedFiles;
final int totalFiles;
const ClientSyncStatus({
required this.stage,
required this.progress,
required this.message,
required this.currentPath,
required this.localBuildHash,
required this.remoteBuildHash,
required this.processedFiles,
required this.totalFiles,
});
}
@@ -0,0 +1,20 @@
sealed class LauncherException implements Exception {
final String message;
const LauncherException(this.message);
@override
String toString() => message;
}
final class LauncherConfigurationException extends LauncherException {
const LauncherConfigurationException(super.message);
}
final class LauncherApiException extends LauncherException {
const LauncherApiException(super.message);
}
final class LauncherSyncException extends LauncherException {
const LauncherSyncException(super.message);
}
@@ -0,0 +1,27 @@
final class LauncherSession {
final String accessToken;
final String tokenType;
final DateTime? expiresAt;
const LauncherSession({
required this.accessToken,
required this.tokenType,
required this.expiresAt,
});
factory LauncherSession.fromJson(Map<String, dynamic> json) {
return LauncherSession(
accessToken: json['access_token'] as String? ?? '',
tokenType: json['token_type'] as String? ?? 'Bearer',
expiresAt: _parseDateTime(json['expires_at'] as String?),
);
}
static DateTime? _parseDateTime(String? rawValue) {
if (rawValue == null || rawValue.isEmpty) {
return null;
}
return DateTime.tryParse(rawValue);
}
}
@@ -0,0 +1,18 @@
import 'client_hash_entry.dart';
final class LocalClientFile implements ClientHashEntry {
@override
final String path;
@override
final int size;
@override
final String sha256;
const LocalClientFile({
required this.path,
required this.size,
required this.sha256,
});
}