базовая логика скачивания обновлений
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user