базовая логика скачивания обновлений
This commit is contained in:
Vendored
+13
-4
@@ -7,19 +7,28 @@
|
|||||||
{
|
{
|
||||||
"name": "moonwell_launcher",
|
"name": "moonwell_launcher",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"type": "dart"
|
"type": "dart",
|
||||||
|
"toolArgs": [
|
||||||
|
"--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "moonwell_launcher (profile mode)",
|
"name": "moonwell_launcher (profile mode)",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"type": "dart",
|
"type": "dart",
|
||||||
"flutterMode": "profile"
|
"flutterMode": "profile",
|
||||||
|
"toolArgs": [
|
||||||
|
"--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "moonwell_launcher (release mode)",
|
"name": "moonwell_launcher (release mode)",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"type": "dart",
|
"type": "dart",
|
||||||
"flutterMode": "release"
|
"flutterMode": "release",
|
||||||
|
"toolArgs": [
|
||||||
|
"--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -4,128 +4,365 @@ import 'package:file_picker/file_picker.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_model.dart';
|
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_model.dart';
|
||||||
import 'package:moonwell_launcher/features/downloader/application/download_manager.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_progress.dart';
|
||||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_request.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/domain/entities/client_manifest.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/launcher_session.dart';
|
||||||
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
|
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
|
||||||
import 'package:rxdart/rxdart.dart';
|
|
||||||
|
|
||||||
part 'home_screen_event.dart';
|
part 'home_screen_event.dart';
|
||||||
part 'home_screen_state.dart';
|
part 'home_screen_state.dart';
|
||||||
|
|
||||||
const _downloadId = 'client';
|
|
||||||
|
|
||||||
class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
||||||
final DownloadManager _downloadManager;
|
|
||||||
final PreferencesRepository _preferencesRepository;
|
|
||||||
|
|
||||||
StreamSubscription<DownloadProgress>? _downloadProgressSubscription;
|
|
||||||
|
|
||||||
HomeScreenBloc({
|
HomeScreenBloc({
|
||||||
required DownloadManager downloadManager,
|
required ClientSyncUseCase clientSyncUseCase,
|
||||||
|
required GameInstallationService gameInstallationService,
|
||||||
required PreferencesRepository preferencesRepository,
|
required PreferencesRepository preferencesRepository,
|
||||||
}) : _downloadManager = downloadManager,
|
required LauncherSession session,
|
||||||
|
required ClientManifest manifest,
|
||||||
|
}) : _clientSyncUseCase = clientSyncUseCase,
|
||||||
|
_gameInstallationService = gameInstallationService,
|
||||||
_preferencesRepository = preferencesRepository,
|
_preferencesRepository = preferencesRepository,
|
||||||
super(HomeScreenInitialState(model: HomeScreenModel.initial())) {
|
_session = session,
|
||||||
_setupHandlers();
|
_manifest = manifest,
|
||||||
|
super(HomeScreenState(
|
||||||
|
model: HomeScreenModel.initial().copyWith(
|
||||||
|
isAuthenticated: true,
|
||||||
|
remoteBuildHash: manifest.buildHash,
|
||||||
|
),
|
||||||
|
)) {
|
||||||
|
on<HomeScreenLoad>(_onHomeScreenLoad);
|
||||||
|
on<HomeScreenSyncRequested>(_onHomeScreenSyncRequested);
|
||||||
|
on<HomeScreenOutputDirRequested>(_onHomeScreenOutputDirRequested);
|
||||||
|
on<HomeScreenPauseRequested>(_onHomeScreenPauseRequested);
|
||||||
|
on<HomeScreenPlayRequested>(_onHomeScreenPlayRequested);
|
||||||
|
on<HomeScreenSyncStatusChanged>(_onHomeScreenSyncStatusChanged);
|
||||||
|
on<HomeScreenSyncFailed>(_onHomeScreenSyncFailed);
|
||||||
|
|
||||||
add(HomeScreenLoad());
|
add(HomeScreenLoad());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setupHandlers() {
|
final ClientSyncUseCase _clientSyncUseCase;
|
||||||
on<HomeScreenLoad>(_onHomeScreenLoad);
|
final GameInstallationService _gameInstallationService;
|
||||||
on<HomeScreenDownloadRequested>(_onHomeScreenDownloadRequested);
|
final PreferencesRepository _preferencesRepository;
|
||||||
on<HomeScreenDownloadPaused>(_onHomeScreenDownloadPaused);
|
|
||||||
on<HomeScreenOutputDirRequested>(_onHomeOutputDirRequested);
|
StreamSubscription<ClientSyncStatus>? _syncSubscription;
|
||||||
on<HomeScreenDownloadProgressUpdated>(
|
final LauncherSession _session;
|
||||||
_onHomeScreenDownloadProgressUpdated,
|
final ClientManifest _manifest;
|
||||||
transformer: (events, mapper) =>
|
bool _pauseRequested = false;
|
||||||
events.throttleTime(const Duration(seconds: 2)).switchMap(mapper),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onHomeScreenLoad(
|
Future<void> _onHomeScreenLoad(
|
||||||
HomeScreenLoad event,
|
HomeScreenLoad event,
|
||||||
Emitter<HomeScreenState> emit,
|
Emitter<HomeScreenState> emit,
|
||||||
) async {
|
) async {
|
||||||
final outputDir = await _preferencesRepository.getOutputDir();
|
final outputPath = await _preferencesRepository.getOutputDir();
|
||||||
|
final hasClientExecutable = await _resolveExecutablePresence(outputPath);
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
HomeScreenReadyToDownloadState(
|
HomeScreenState(
|
||||||
model: state.model.copyWith(outputPath: outputDir),
|
model: state.model.copyWith(
|
||||||
|
outputPath: outputPath,
|
||||||
|
hasClientExecutable: hasClientExecutable,
|
||||||
|
phase: _stablePhase(hasClientExecutable),
|
||||||
|
statusText: _buildIdleStatus(
|
||||||
|
isAuthenticated: state.model.isAuthenticated,
|
||||||
|
outputPath: outputPath,
|
||||||
|
hasClientExecutable: hasClientExecutable,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onHomeScreenDownloadRequested(
|
Future<void> _onHomeScreenSyncRequested(
|
||||||
HomeScreenDownloadRequested event,
|
HomeScreenSyncRequested event,
|
||||||
Emitter<HomeScreenState> emit,
|
Emitter<HomeScreenState> emit,
|
||||||
) async {
|
) async {
|
||||||
if (state.model.outputPath == null) {
|
if (state.model.isSyncing) {
|
||||||
emit(HomeScreenOutputDirSelectionState(model: state.model.copyWith()));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_downloadProgressSubscription = _downloadManager
|
final outputPath = state.model.outputPath;
|
||||||
.start(
|
if (outputPath == null) {
|
||||||
id: _downloadId,
|
add(HomeScreenOutputDirRequested());
|
||||||
request: DownloadRequest(
|
return;
|
||||||
destinationPath: state.model.outputPath!.toFilePath(),
|
}
|
||||||
),
|
|
||||||
)
|
|
||||||
.listen((progress) => add(HomeScreenDownloadProgressUpdated(progress)));
|
|
||||||
|
|
||||||
emit(HomeScreenDownloadInitializing(model: state.model.copyWith()));
|
await _syncSubscription?.cancel();
|
||||||
}
|
_pauseRequested = false;
|
||||||
|
|
||||||
void _onHomeScreenDownloadProgressUpdated(
|
|
||||||
HomeScreenDownloadProgressUpdated event,
|
|
||||||
Emitter<HomeScreenState> emit,
|
|
||||||
) {
|
|
||||||
emit(
|
emit(
|
||||||
HomeScreenDownloadingState(
|
HomeScreenState(
|
||||||
model: state.model.copyWith(progress: event.progress),
|
model: state.model.copyWith(
|
||||||
|
phase: HomeScreenPhase.syncing,
|
||||||
|
progress: const DownloadProgress.initial(),
|
||||||
|
statusText: 'Подготавливаю проверку клиента...',
|
||||||
|
currentPath: null,
|
||||||
|
errorMessage: null,
|
||||||
|
processedFiles: 0,
|
||||||
|
totalFiles: 0,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_syncSubscription = _clientSyncUseCase
|
||||||
|
.call(
|
||||||
|
ClientSyncUseCaseInput(
|
||||||
|
request: ClientSyncRequest(
|
||||||
|
installationDir: outputPath.toFilePath(),
|
||||||
|
accessToken: _session.accessToken,
|
||||||
|
manifest: _manifest,
|
||||||
|
),
|
||||||
|
isCancelled: () async => _pauseRequested,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.listen(
|
||||||
|
(status) => add(HomeScreenSyncStatusChanged(status)),
|
||||||
|
onError: (error, _) => add(HomeScreenSyncFailed(error)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Future<void> _onHomeScreenOutputDirRequested(
|
||||||
Future<void> close() async {
|
|
||||||
await _downloadProgressSubscription?.cancel();
|
|
||||||
await _downloadManager.cancel(_downloadId);
|
|
||||||
|
|
||||||
return super.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
FutureOr<void> _onHomeScreenDownloadPaused(
|
|
||||||
HomeScreenDownloadPaused event,
|
|
||||||
Emitter<HomeScreenState> emit,
|
|
||||||
) async {
|
|
||||||
await _downloadManager.pause(_downloadId);
|
|
||||||
|
|
||||||
emit(HomeScreenDownloadPausedState(model: state.model.copyWith()));
|
|
||||||
}
|
|
||||||
|
|
||||||
FutureOr<void> _onHomeOutputDirRequested(
|
|
||||||
HomeScreenOutputDirRequested event,
|
HomeScreenOutputDirRequested event,
|
||||||
Emitter<HomeScreenState> emit,
|
Emitter<HomeScreenState> emit,
|
||||||
) async {
|
) async {
|
||||||
emit(HomeScreenReadyToDownloadState(model: state.model.copyWith()));
|
if (state.model.isSyncing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final directory = await FilePicker.platform.getDirectoryPath(
|
final directory = await FilePicker.platform.getDirectoryPath(
|
||||||
dialogTitle: 'Please select installation directory',
|
dialogTitle: 'Выберите папку установки Moonwell',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (directory == null) {
|
if (directory == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _preferencesRepository.setOutputDir(Uri.directory(directory));
|
final outputPath = Uri.directory(directory);
|
||||||
|
await _preferencesRepository.setOutputDir(outputPath);
|
||||||
|
|
||||||
|
final hasClientExecutable = await _resolveExecutablePresence(outputPath);
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
HomeScreenReadyToDownloadState(
|
HomeScreenState(
|
||||||
model: state.model.copyWith(outputPath: Uri.directory(directory)),
|
model: state.model.copyWith(
|
||||||
|
outputPath: outputPath,
|
||||||
|
hasClientExecutable: hasClientExecutable,
|
||||||
|
phase: _stablePhase(hasClientExecutable),
|
||||||
|
statusText: _buildIdleStatus(
|
||||||
|
isAuthenticated: state.model.isAuthenticated,
|
||||||
|
outputPath: outputPath,
|
||||||
|
hasClientExecutable: hasClientExecutable,
|
||||||
|
),
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
add(HomeScreenSyncRequested());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onHomeScreenPauseRequested(
|
||||||
|
HomeScreenPauseRequested event,
|
||||||
|
Emitter<HomeScreenState> emit,
|
||||||
|
) async {
|
||||||
|
if (!state.model.isSyncing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pauseRequested = true;
|
||||||
|
|
||||||
|
emit(
|
||||||
|
HomeScreenState(
|
||||||
|
model: state.model.copyWith(
|
||||||
|
statusText: 'Останавливаю обновление...',
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onHomeScreenPlayRequested(
|
||||||
|
HomeScreenPlayRequested event,
|
||||||
|
Emitter<HomeScreenState> emit,
|
||||||
|
) async {
|
||||||
|
final outputPath = state.model.outputPath;
|
||||||
|
if (outputPath == null) {
|
||||||
|
emit(_buildErrorState('Сначала выберите папку установки.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final installationDir = outputPath.toFilePath();
|
||||||
|
await _gameInstallationService.clearCache(installationDir);
|
||||||
|
await _gameInstallationService.launchGame(installationDir);
|
||||||
|
|
||||||
|
emit(
|
||||||
|
HomeScreenState(
|
||||||
|
model: state.model.copyWith(
|
||||||
|
phase: HomeScreenPhase.readyToPlay,
|
||||||
|
statusText: 'Игра запущена. Папка Cache очищена перед стартом.',
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
emit(
|
||||||
|
_buildErrorState(
|
||||||
|
'Не удалось запустить игру.',
|
||||||
|
errorMessage: _formatError(error),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onHomeScreenSyncStatusChanged(
|
||||||
|
HomeScreenSyncStatusChanged event,
|
||||||
|
Emitter<HomeScreenState> emit,
|
||||||
|
) async {
|
||||||
|
final outputPath = state.model.outputPath;
|
||||||
|
final isCompleted = event.status.stage == ClientSyncStage.completed;
|
||||||
|
final hasClientExecutable = isCompleted
|
||||||
|
? await _resolveExecutablePresence(outputPath)
|
||||||
|
: state.model.hasClientExecutable;
|
||||||
|
|
||||||
|
emit(
|
||||||
|
HomeScreenState(
|
||||||
|
model: state.model.copyWith(
|
||||||
|
progress: event.status.progress,
|
||||||
|
phase: isCompleted
|
||||||
|
? _stablePhase(hasClientExecutable)
|
||||||
|
: HomeScreenPhase.syncing,
|
||||||
|
hasClientExecutable: hasClientExecutable,
|
||||||
|
statusText: event.status.message,
|
||||||
|
currentPath: event.status.currentPath,
|
||||||
|
errorMessage: null,
|
||||||
|
localBuildHash: event.status.localBuildHash,
|
||||||
|
remoteBuildHash: event.status.remoteBuildHash,
|
||||||
|
processedFiles: event.status.processedFiles,
|
||||||
|
totalFiles: event.status.totalFiles,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onHomeScreenSyncFailed(
|
||||||
|
HomeScreenSyncFailed event,
|
||||||
|
Emitter<HomeScreenState> emit,
|
||||||
|
) async {
|
||||||
|
final hasClientExecutable = await _resolveExecutablePresence(
|
||||||
|
state.model.outputPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_pauseRequested || event.error is CancelledException) {
|
||||||
|
_pauseRequested = false;
|
||||||
|
|
||||||
|
emit(
|
||||||
|
HomeScreenState(
|
||||||
|
model: state.model.copyWith(
|
||||||
|
phase: HomeScreenPhase.paused,
|
||||||
|
hasClientExecutable: hasClientExecutable,
|
||||||
|
statusText: 'Обновление приостановлено.',
|
||||||
|
currentPath: null,
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(
|
||||||
|
HomeScreenState(
|
||||||
|
model: state.model.copyWith(
|
||||||
|
phase: HomeScreenPhase.failure,
|
||||||
|
hasClientExecutable: hasClientExecutable,
|
||||||
|
statusText: 'Не удалось завершить обновление клиента.',
|
||||||
|
currentPath: null,
|
||||||
|
errorMessage: _formatError(event.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
HomeScreenState _buildErrorState(String statusText, {String? errorMessage}) {
|
||||||
|
final hasClientExecutable = state.model.hasClientExecutable;
|
||||||
|
|
||||||
|
return HomeScreenState(
|
||||||
|
model: state.model.copyWith(
|
||||||
|
phase: hasClientExecutable
|
||||||
|
? HomeScreenPhase.readyToPlay
|
||||||
|
: HomeScreenPhase.failure,
|
||||||
|
statusText: statusText,
|
||||||
|
errorMessage: errorMessage ?? statusText,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
HomeScreenPhase _stablePhase(bool hasClientExecutable) {
|
||||||
|
return hasClientExecutable
|
||||||
|
? HomeScreenPhase.readyToPlay
|
||||||
|
: HomeScreenPhase.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _resolveExecutablePresence(Uri? outputPath) async {
|
||||||
|
if (outputPath == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _gameInstallationService.hasClientExecutable(
|
||||||
|
outputPath.toFilePath(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildIdleStatus({
|
||||||
|
required bool isAuthenticated,
|
||||||
|
required Uri? outputPath,
|
||||||
|
required bool hasClientExecutable,
|
||||||
|
}) {
|
||||||
|
if (hasClientExecutable && isAuthenticated) {
|
||||||
|
return 'Клиент найден. Можно играть или проверять обновления.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasClientExecutable) {
|
||||||
|
return 'Клиент найден. Можно играть или авторизоваться для проверки обновлений.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outputPath == null && !isAuthenticated) {
|
||||||
|
return 'Выберите папку установки и авторизуйтесь.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outputPath == null) {
|
||||||
|
return 'Авторизация выполнена. Выберите папку установки.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return 'Папка выбрана. Можно устанавливать или обновлять клиент.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Папка выбрана. Авторизуйтесь для проверки файлов.';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatError(Object error) {
|
||||||
|
if (error is LauncherException) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error is DownloadException) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
final raw = error.toString();
|
||||||
|
return raw.startsWith('Exception: ') ? raw.substring(11) : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async {
|
||||||
|
_pauseRequested = true;
|
||||||
|
await _syncSubscription?.cancel();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,22 @@ sealed class HomeScreenEvent {}
|
|||||||
|
|
||||||
final class HomeScreenLoad extends HomeScreenEvent {}
|
final class HomeScreenLoad extends HomeScreenEvent {}
|
||||||
|
|
||||||
final class HomeScreenDownloadRequested extends HomeScreenEvent {}
|
final class HomeScreenSyncRequested extends HomeScreenEvent {}
|
||||||
|
|
||||||
final class HomeScreenOutputDirRequested extends HomeScreenEvent {}
|
final class HomeScreenOutputDirRequested extends HomeScreenEvent {}
|
||||||
|
|
||||||
final class HomeScreenDownloadPaused extends HomeScreenEvent {}
|
final class HomeScreenPauseRequested extends HomeScreenEvent {}
|
||||||
|
|
||||||
final class HomeScreenDownloadProgressUpdated extends HomeScreenEvent {
|
final class HomeScreenPlayRequested extends HomeScreenEvent {}
|
||||||
final DownloadProgress progress;
|
|
||||||
|
|
||||||
HomeScreenDownloadProgressUpdated(this.progress);
|
final class HomeScreenSyncStatusChanged extends HomeScreenEvent {
|
||||||
|
final ClientSyncStatus status;
|
||||||
|
|
||||||
|
HomeScreenSyncStatusChanged(this.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class HomeScreenSyncFailed extends HomeScreenEvent {
|
||||||
|
final Object error;
|
||||||
|
|
||||||
|
HomeScreenSyncFailed(this.error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,107 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
|
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
|
||||||
|
|
||||||
|
enum HomeScreenPhase {
|
||||||
|
idle,
|
||||||
|
ready,
|
||||||
|
authenticating,
|
||||||
|
syncing,
|
||||||
|
paused,
|
||||||
|
readyToPlay,
|
||||||
|
failure,
|
||||||
|
}
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
final class HomeScreenModel {
|
final class HomeScreenModel {
|
||||||
/// The current download progress.
|
static const Object _sentinel = Object();
|
||||||
|
|
||||||
final DownloadProgress progress;
|
final DownloadProgress progress;
|
||||||
|
|
||||||
/// The output path for the downloaded file.
|
|
||||||
final Uri? outputPath;
|
final Uri? outputPath;
|
||||||
|
final HomeScreenPhase phase;
|
||||||
|
final bool isAuthenticated;
|
||||||
|
final bool hasClientExecutable;
|
||||||
|
final String statusText;
|
||||||
|
final String? currentPath;
|
||||||
|
final String? errorMessage;
|
||||||
|
final String? localBuildHash;
|
||||||
|
final String? remoteBuildHash;
|
||||||
|
final int processedFiles;
|
||||||
|
final int totalFiles;
|
||||||
|
|
||||||
const HomeScreenModel({required this.progress, this.outputPath});
|
const HomeScreenModel({
|
||||||
|
required this.progress,
|
||||||
|
required this.outputPath,
|
||||||
|
required this.phase,
|
||||||
|
required this.isAuthenticated,
|
||||||
|
required this.hasClientExecutable,
|
||||||
|
required this.statusText,
|
||||||
|
required this.currentPath,
|
||||||
|
required this.errorMessage,
|
||||||
|
required this.localBuildHash,
|
||||||
|
required this.remoteBuildHash,
|
||||||
|
required this.processedFiles,
|
||||||
|
required this.totalFiles,
|
||||||
|
});
|
||||||
|
|
||||||
const HomeScreenModel.initial()
|
const HomeScreenModel.initial()
|
||||||
: progress = const DownloadProgress.initial(),
|
: progress = const DownloadProgress.initial(),
|
||||||
outputPath = null;
|
outputPath = null,
|
||||||
|
phase = HomeScreenPhase.idle,
|
||||||
|
isAuthenticated = false,
|
||||||
|
hasClientExecutable = false,
|
||||||
|
statusText = 'Загрузка...',
|
||||||
|
currentPath = null,
|
||||||
|
errorMessage = null,
|
||||||
|
localBuildHash = null,
|
||||||
|
remoteBuildHash = null,
|
||||||
|
processedFiles = 0,
|
||||||
|
totalFiles = 0;
|
||||||
|
|
||||||
HomeScreenModel copyWith({DownloadProgress? progress, Uri? outputPath}) {
|
bool get isBusy =>
|
||||||
|
phase == HomeScreenPhase.authenticating ||
|
||||||
|
phase == HomeScreenPhase.syncing;
|
||||||
|
|
||||||
|
bool get isSyncing => phase == HomeScreenPhase.syncing;
|
||||||
|
|
||||||
|
bool get canPlay => hasClientExecutable && !isBusy;
|
||||||
|
|
||||||
|
HomeScreenModel copyWith({
|
||||||
|
DownloadProgress? progress,
|
||||||
|
Object? outputPath = _sentinel,
|
||||||
|
HomeScreenPhase? phase,
|
||||||
|
bool? isAuthenticated,
|
||||||
|
bool? hasClientExecutable,
|
||||||
|
String? statusText,
|
||||||
|
Object? currentPath = _sentinel,
|
||||||
|
Object? errorMessage = _sentinel,
|
||||||
|
Object? localBuildHash = _sentinel,
|
||||||
|
Object? remoteBuildHash = _sentinel,
|
||||||
|
int? processedFiles,
|
||||||
|
int? totalFiles,
|
||||||
|
}) {
|
||||||
return HomeScreenModel(
|
return HomeScreenModel(
|
||||||
progress: progress ?? this.progress,
|
progress: progress ?? this.progress,
|
||||||
outputPath: outputPath ?? this.outputPath,
|
outputPath: outputPath == _sentinel
|
||||||
|
? this.outputPath
|
||||||
|
: outputPath as Uri?,
|
||||||
|
phase: phase ?? this.phase,
|
||||||
|
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
|
||||||
|
hasClientExecutable: hasClientExecutable ?? this.hasClientExecutable,
|
||||||
|
statusText: statusText ?? this.statusText,
|
||||||
|
currentPath: currentPath == _sentinel
|
||||||
|
? this.currentPath
|
||||||
|
: currentPath as String?,
|
||||||
|
errorMessage: errorMessage == _sentinel
|
||||||
|
? this.errorMessage
|
||||||
|
: errorMessage as String?,
|
||||||
|
localBuildHash: localBuildHash == _sentinel
|
||||||
|
? this.localBuildHash
|
||||||
|
: localBuildHash as String?,
|
||||||
|
remoteBuildHash: remoteBuildHash == _sentinel
|
||||||
|
? this.remoteBuildHash
|
||||||
|
: remoteBuildHash as String?,
|
||||||
|
processedFiles: processedFiles ?? this.processedFiles,
|
||||||
|
totalFiles: totalFiles ?? this.totalFiles,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,8 @@
|
|||||||
part of 'home_screen_bloc.dart';
|
part of 'home_screen_bloc.dart';
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
sealed class HomeScreenState {
|
final class HomeScreenState {
|
||||||
final HomeScreenModel model;
|
final HomeScreenModel model;
|
||||||
|
|
||||||
const HomeScreenState({required this.model});
|
const HomeScreenState({required this.model});
|
||||||
}
|
}
|
||||||
|
|
||||||
final class HomeScreenInitialState extends HomeScreenState {
|
|
||||||
const HomeScreenInitialState({required super.model});
|
|
||||||
}
|
|
||||||
|
|
||||||
final class HomeScreenReadyToDownloadState extends HomeScreenState {
|
|
||||||
const HomeScreenReadyToDownloadState({required super.model});
|
|
||||||
}
|
|
||||||
|
|
||||||
final class HomeScreenDownloadPausedState extends HomeScreenState {
|
|
||||||
const HomeScreenDownloadPausedState({required super.model});
|
|
||||||
}
|
|
||||||
|
|
||||||
final class HomeScreenDownloadInitializing extends HomeScreenState {
|
|
||||||
const HomeScreenDownloadInitializing({required super.model});
|
|
||||||
}
|
|
||||||
|
|
||||||
final class HomeScreenDownloadingState extends HomeScreenState {
|
|
||||||
const HomeScreenDownloadingState({required super.model});
|
|
||||||
}
|
|
||||||
|
|
||||||
final class HomeScreenOutputDirSelectionState extends HomeScreenState {
|
|
||||||
const HomeScreenOutputDirSelectionState({required super.model});
|
|
||||||
}
|
|
||||||
|
|
||||||
final class HomeScreenReadyToPlayState extends HomeScreenState {
|
|
||||||
const HomeScreenReadyToPlayState({required super.model});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,95 +1,129 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart';
|
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart';
|
||||||
import 'package:moonwell_launcher/app/home_screen/home_screen_content.dart';
|
import 'package:moonwell_launcher/app/home_screen/home_screen_content.dart';
|
||||||
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
|
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
|
||||||
import 'package:moonwell_launcher/features/downloader/application/download_manager.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/domain/entities/client_manifest.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
|
||||||
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
|
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
|
||||||
import 'package:moonwell_launcher/service_container.dart';
|
import 'package:moonwell_launcher/service_container.dart';
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key, required this.session, required this.manifest});
|
||||||
|
|
||||||
|
final LauncherSession session;
|
||||||
|
final ClientManifest manifest;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return BlocProvider<HomeScreenBloc>(
|
return BlocProvider<HomeScreenBloc>(
|
||||||
create: (context) => HomeScreenBloc(
|
create: (context) => HomeScreenBloc(
|
||||||
downloadManager: getIt<DownloadManager>(),
|
clientSyncUseCase: getIt<ClientSyncUseCase>(),
|
||||||
|
gameInstallationService: getIt<GameInstallationService>(),
|
||||||
preferencesRepository: getIt<PreferencesRepository>(),
|
preferencesRepository: getIt<PreferencesRepository>(),
|
||||||
|
session: session,
|
||||||
|
manifest: manifest,
|
||||||
),
|
),
|
||||||
child: BlocConsumer<HomeScreenBloc, HomeScreenState>(
|
child: Scaffold(
|
||||||
listener: (context, state) {
|
body: Stack(
|
||||||
if (state is HomeScreenOutputDirSelectionState) {
|
children: [
|
||||||
showDialog(
|
// Background image
|
||||||
context: context,
|
Positioned.fill(
|
||||||
builder: (dialogContext) => AlertDialog(
|
child: Image.asset('assets/background.png', fit: BoxFit.cover),
|
||||||
title: Text('Alert'),
|
),
|
||||||
content: Text('Please select client location.'),
|
// Gradient overlay
|
||||||
actions: [
|
Positioned.fill(
|
||||||
ElevatedButton.icon(
|
child: DecoratedBox(
|
||||||
icon: const Icon(Icons.folder_open),
|
decoration: BoxDecoration(
|
||||||
onPressed: () => context.read<HomeScreenBloc>().add(
|
gradient: LinearGradient(
|
||||||
HomeScreenOutputDirRequested(),
|
begin: Alignment.topCenter,
|
||||||
),
|
end: Alignment.bottomCenter,
|
||||||
label: Text('Select directory'),
|
colors: [
|
||||||
),
|
MWColors.abyss.withAlpha(180),
|
||||||
],
|
MWColors.abyss.withAlpha(220),
|
||||||
),
|
MWColors.abyss.withAlpha(240),
|
||||||
);
|
],
|
||||||
}
|
stops: const [0.0, 0.5, 1.0],
|
||||||
},
|
|
||||||
|
|
||||||
builder: (context, state) {
|
|
||||||
return Scaffold(
|
|
||||||
body: Stack(
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: Container(
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
// subtle vignette for “night” vibe
|
|
||||||
gradient: RadialGradient(
|
|
||||||
center: Alignment(0, -0.5),
|
|
||||||
radius: 1.2,
|
|
||||||
colors: [MWColors.deepNavy, MWColors.abyss],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.all(24.0),
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: Image.asset(
|
|
||||||
'assets/background.png',
|
|
||||||
fit: BoxFit.fitWidth,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: HomeScreenContent(
|
|
||||||
title: Text(
|
|
||||||
'MoonWell',
|
|
||||||
style: theme.textTheme.headlineLarge,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (state is HomeScreenDownloadInitializing)
|
),
|
||||||
Center(child: const CircularProgressIndicator()),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
// Main content
|
||||||
},
|
const Positioned.fill(child: HomeScreenContent()),
|
||||||
|
// Custom title bar
|
||||||
|
const Positioned(top: 0, left: 0, right: 0, child: _TitleBar()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TitleBar extends StatelessWidget {
|
||||||
|
const _TitleBar();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onPanStart: (_) => windowManager.startDragging(),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 36,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Spacer(),
|
||||||
|
_TitleBarButton(
|
||||||
|
icon: Icons.remove,
|
||||||
|
onTap: () => windowManager.minimize(),
|
||||||
|
color: cs.onSurface,
|
||||||
|
),
|
||||||
|
_TitleBarButton(
|
||||||
|
icon: Icons.crop_square,
|
||||||
|
onTap: () async {
|
||||||
|
if (await windowManager.isMaximized()) {
|
||||||
|
await windowManager.unmaximize();
|
||||||
|
} else {
|
||||||
|
await windowManager.maximize();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
color: cs.onSurface,
|
||||||
|
),
|
||||||
|
_TitleBarButton(
|
||||||
|
icon: Icons.close,
|
||||||
|
onTap: () => SystemNavigator.pop(),
|
||||||
|
color: MWColors.error,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TitleBarButton extends StatelessWidget {
|
||||||
|
const _TitleBarButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.onTap,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 36,
|
||||||
|
child: Icon(icon, size: 16, color: color.withAlpha(180)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,184 +1,455 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart';
|
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart';
|
||||||
|
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_model.dart';
|
||||||
import 'package:moonwell_launcher/app/home_screen/widgets/gilded_progress_bar.dart';
|
import 'package:moonwell_launcher/app/home_screen/widgets/gilded_progress_bar.dart';
|
||||||
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
|
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
|
||||||
import 'package:pixelarticons/pixel.dart';
|
import 'package:pixelarticons/pixel.dart';
|
||||||
|
|
||||||
class HomeScreenContent extends StatelessWidget {
|
class HomeScreenContent extends StatelessWidget {
|
||||||
const HomeScreenContent({super.key, required this.title});
|
const HomeScreenContent({super.key});
|
||||||
|
|
||||||
final Widget title;
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = context.watch<HomeScreenBloc>().state;
|
||||||
|
final model = state.model;
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
String _speedLabel(BuildContext context) {
|
return Padding(
|
||||||
final kbps = context.read<HomeScreenBloc>().state.model.progress.speed;
|
padding: const EdgeInsets.only(top: 36),
|
||||||
|
child: Column(
|
||||||
if (kbps <= 0) return '—';
|
children: [
|
||||||
if (kbps >= 1024) {
|
// Main content area
|
||||||
return '${(kbps / 1024).toStringAsFixed(2)} MB/s';
|
Expanded(
|
||||||
}
|
child: Row(
|
||||||
return '${kbps.toStringAsFixed(0)} kB/s';
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Left panel — News
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: _NewsPanel(cs: cs),
|
||||||
|
),
|
||||||
|
// Right panel — Status & Controls
|
||||||
|
SizedBox(
|
||||||
|
width: 320,
|
||||||
|
child: _RightPanel(model: model, cs: cs),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Bottom bar
|
||||||
|
_BottomBar(model: model, cs: cs),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// News panel (left side)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class _NewsPanel extends StatelessWidget {
|
||||||
|
const _NewsPanel({required this.cs});
|
||||||
|
final ColorScheme cs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 24, top: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Logo
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/logo.png',
|
||||||
|
height: 120,
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Новости',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: cs.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// News list
|
||||||
|
Expanded(
|
||||||
|
child: ShaderMask(
|
||||||
|
shaderCallback: (bounds) => LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.white,
|
||||||
|
Colors.white,
|
||||||
|
Colors.white.withAlpha(0),
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.85, 1.0],
|
||||||
|
).createShader(bounds),
|
||||||
|
blendMode: BlendMode.dstIn,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.only(right: 16, bottom: 24),
|
||||||
|
children: const [
|
||||||
|
_NewsCard(
|
||||||
|
title: 'Добро пожаловать в MoonWell!',
|
||||||
|
description:
|
||||||
|
'Мы рады приветствовать вас на нашем сервере. '
|
||||||
|
'Установите клиент, авторизуйтесь и окунитесь '
|
||||||
|
'в мир приключений!',
|
||||||
|
),
|
||||||
|
SizedBox(height: 10),
|
||||||
|
_NewsCard(
|
||||||
|
title: 'Обновление клиента',
|
||||||
|
description:
|
||||||
|
'Лаунчер автоматически проверит и загрузит '
|
||||||
|
'последние обновления игрового клиента при входе.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NewsCard extends StatelessWidget {
|
||||||
|
const _NewsCard({required this.title, required this.description});
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surface.withAlpha(140),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: cs.outline.withAlpha(100)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: cs.tertiary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: cs.onSurface.withAlpha(200),
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Right panel (status & controls)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class _RightPanel extends StatelessWidget {
|
||||||
|
const _RightPanel({required this.model, required this.cs});
|
||||||
|
|
||||||
|
final HomeScreenModel model;
|
||||||
|
final ColorScheme cs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final deco = Theme.of(context).extension<MoonWellDecorations>()!;
|
final deco = Theme.of(context).extension<MoonWellDecorations>()!;
|
||||||
|
|
||||||
final state = context.read<HomeScreenBloc>().state;
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(right: 20, top: 8, bottom: 12),
|
||||||
final percent = _resolvePercentage(context);
|
padding: const EdgeInsets.all(24),
|
||||||
final eta = state.model.progress.eta;
|
decoration: BoxDecoration(
|
||||||
|
color: MWColors.abyss.withAlpha(200),
|
||||||
final isDownloading = state is HomeScreenDownloadingState;
|
borderRadius: BorderRadius.circular(20),
|
||||||
final canPlay = state is HomeScreenReadyToPlayState;
|
border: Border.all(color: cs.outline.withAlpha(80)),
|
||||||
|
boxShadow: deco.cardGlow,
|
||||||
return Card(
|
),
|
||||||
margin: EdgeInsets.zero,
|
child: Column(
|
||||||
child: Padding(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
padding: const EdgeInsets.all(24),
|
children: [
|
||||||
child: Column(
|
const Spacer(),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
// Folder selector
|
||||||
children: [
|
GestureDetector(
|
||||||
title,
|
behavior: HitTestBehavior.opaque,
|
||||||
const SizedBox(height: 16),
|
onTap: () => context.read<HomeScreenBloc>().add(
|
||||||
Text(
|
HomeScreenOutputDirRequested(),
|
||||||
'Classic+ Wrath of the Lich King experience',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
color: cs.onSurface.withAlpha(200),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
child: Container(
|
||||||
const Spacer(),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
// Progress bar
|
|
||||||
GildedProgressBar(
|
|
||||||
value: state.model.progress.total == 0
|
|
||||||
? 0
|
|
||||||
: state.model.progress.downloaded /
|
|
||||||
state.model.progress.total,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'$percent%',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Icon(Pixel.speedfast, size: 18, color: cs.onSurfaceVariant),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
_speedLabel(context),
|
|
||||||
style: TextStyle(color: cs.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Icon(
|
|
||||||
Pixel.timeline, // Updated to use PixelArtIcons
|
|
||||||
size: 18,
|
|
||||||
color: cs.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
eta == Duration.zero
|
|
||||||
? '—'
|
|
||||||
: '${eta.inMinutes}:${(eta.inSeconds % 60).toString().padLeft(2, '0')}',
|
|
||||||
style: TextStyle(color: cs.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Controls row
|
|
||||||
Row(
|
|
||||||
spacing: 12.0,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
icon: Icon(
|
|
||||||
canPlay ? Icons.play_arrow_rounded : Icons.download,
|
|
||||||
),
|
|
||||||
label: Text(
|
|
||||||
canPlay
|
|
||||||
? 'Play'
|
|
||||||
: (isDownloading
|
|
||||||
? 'Downloading…'
|
|
||||||
: (state.model.progress.downloaded == 0
|
|
||||||
? 'Install'
|
|
||||||
: 'Resume')),
|
|
||||||
),
|
|
||||||
onPressed: canPlay
|
|
||||||
? null
|
|
||||||
: (isDownloading ? null : () => _onStart(context)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
icon: Icon(Pixel.pause),
|
|
||||||
label: Text('Pause'),
|
|
||||||
onPressed: isDownloading ? () => _onPause(context) : null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
// Disk path / build info (placeholders)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cs.surfaceContainerHighest.withAlpha(128),
|
color: cs.surface.withAlpha(100),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: cs.outline),
|
border: Border.all(color: cs.outline.withAlpha(80)),
|
||||||
boxShadow: (deco.cardGlow),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Pixel.folder),
|
Icon(Pixel.folder, size: 18, color: cs.onSurfaceVariant),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: GestureDetector(
|
child: Text(
|
||||||
behavior: HitTestBehavior.opaque,
|
model.outputPath?.toFilePath() ?? 'Выбрать папку',
|
||||||
onTap: () => context.read<HomeScreenBloc>().add(
|
overflow: TextOverflow.ellipsis,
|
||||||
HomeScreenOutputDirRequested(),
|
style: TextStyle(
|
||||||
),
|
color: cs.onSurfaceVariant,
|
||||||
child: Text(
|
fontSize: 13,
|
||||||
'Install path: ${state.model.outputPath?.toFilePath() ?? '—'}',
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
Icon(Icons.edit, size: 14, color: cs.onSurfaceVariant.withAlpha(120)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
|
// Status
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surface.withAlpha(80),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: cs.outline.withAlpha(60)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
model.statusText,
|
||||||
|
style: TextStyle(color: cs.onSurface.withAlpha(180), fontSize: 13),
|
||||||
|
),
|
||||||
|
if (model.currentPath != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
model.currentPath!,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (model.totalFiles > 0) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Файлы: ${model.processedFiles}/${model.totalFiles}',
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (model.errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
model.errorMessage!,
|
||||||
|
style: TextStyle(color: cs.error, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
// Build hashes
|
||||||
|
if (model.remoteBuildHash != null || model.localBuildHash != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text(
|
||||||
|
'Build: ${_shortHash(model.localBuildHash)} / ${_shortHash(model.remoteBuildHash)}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _resolvePercentage(BuildContext context) {
|
String _shortHash(String? hash) {
|
||||||
final state = context.read<HomeScreenBloc>().state;
|
if (hash == null || hash.isEmpty) return '—';
|
||||||
|
if (hash.length <= 12) return hash;
|
||||||
if (state.model.progress.total == 0) {
|
return '${hash.substring(0, 8)}...${hash.substring(hash.length - 4)}';
|
||||||
return '0.0';
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final percentage =
|
// ---------------------------------------------------------------------------
|
||||||
state.model.progress.downloaded / state.model.progress.total;
|
// Bottom bar (play button, progress, version)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
return (percentage * 100).clamp(0, 100).toStringAsFixed(1);
|
class _BottomBar extends StatelessWidget {
|
||||||
}
|
const _BottomBar({required this.model, required this.cs});
|
||||||
|
|
||||||
void _onStart(BuildContext context) {
|
final HomeScreenModel model;
|
||||||
context.read<HomeScreenBloc>().add(HomeScreenDownloadRequested());
|
final ColorScheme cs;
|
||||||
}
|
|
||||||
|
@override
|
||||||
void _onPause(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
context.read<HomeScreenBloc>().add(HomeScreenDownloadPaused());
|
final progressValue = model.progress.total == 0
|
||||||
}
|
? 0.0
|
||||||
|
: model.progress.downloaded / model.progress.total;
|
||||||
void _onReset(BuildContext context) {
|
|
||||||
// context.read<HomeScreenBloc>().add(HomeScreenDownloadReset());
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
MWColors.abyss.withAlpha(0),
|
||||||
|
MWColors.abyss.withAlpha(230),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Progress bar (only when syncing)
|
||||||
|
if (model.isSyncing) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: GildedProgressBar(value: progressValue)),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'${(progressValue * 100).clamp(0, 100).toStringAsFixed(1)}%',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
_formatSpeed(model.progress.speed),
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
// Bottom row: play + status
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// Play button
|
||||||
|
SizedBox(
|
||||||
|
height: 44,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: Icon(_resolvePrimaryIcon(model), size: 20),
|
||||||
|
label: Text(_resolvePrimaryLabel(model)),
|
||||||
|
onPressed: model.isBusy
|
||||||
|
? null
|
||||||
|
: () => _onPrimaryAction(context, model),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (model.canPlay || model.isSyncing) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SizedBox(
|
||||||
|
height: 44,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
icon: Icon(_resolveSecondaryIcon(model), size: 18),
|
||||||
|
label: Text(_resolveSecondaryLabel(model)),
|
||||||
|
onPressed: () => _onSecondaryAction(context, model),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
// Status ticker
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
height: 38,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surface.withAlpha(100),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: cs.outline.withAlpha(60)),
|
||||||
|
),
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
model.currentPath ?? model.statusText,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface.withAlpha(160),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPrimaryAction(BuildContext context, HomeScreenModel model) {
|
||||||
|
final bloc = context.read<HomeScreenBloc>();
|
||||||
|
|
||||||
|
if (model.canPlay) {
|
||||||
|
bloc.add(HomeScreenPlayRequested());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bloc.add(HomeScreenSyncRequested());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSecondaryAction(BuildContext context, HomeScreenModel model) {
|
||||||
|
final bloc = context.read<HomeScreenBloc>();
|
||||||
|
|
||||||
|
if (model.isSyncing) {
|
||||||
|
bloc.add(HomeScreenPauseRequested());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.canPlay) {
|
||||||
|
bloc.add(HomeScreenSyncRequested());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bloc.add(HomeScreenOutputDirRequested());
|
||||||
|
}
|
||||||
|
|
||||||
|
String _resolvePrimaryLabel(HomeScreenModel model) {
|
||||||
|
if (model.isSyncing) return 'Обновление...';
|
||||||
|
if (model.canPlay) return 'Играть';
|
||||||
|
return model.hasClientExecutable ? 'Обновить' : 'Установить';
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _resolvePrimaryIcon(HomeScreenModel model) {
|
||||||
|
if (model.canPlay) return Icons.play_arrow_rounded;
|
||||||
|
return Icons.download_rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _resolveSecondaryLabel(HomeScreenModel model) {
|
||||||
|
if (model.isSyncing) return 'Пауза';
|
||||||
|
if (model.canPlay) return 'Проверить';
|
||||||
|
return 'Папка';
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _resolveSecondaryIcon(HomeScreenModel model) {
|
||||||
|
if (model.isSyncing) return Pixel.pause;
|
||||||
|
if (model.canPlay) return Icons.refresh_rounded;
|
||||||
|
return Pixel.folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatSpeed(int bytesPerSecond) {
|
||||||
|
if (bytesPerSecond <= 0) return '—';
|
||||||
|
if (bytesPerSecond >= 1024 * 1024) {
|
||||||
|
return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(2)} MB/s';
|
||||||
|
}
|
||||||
|
if (bytesPerSecond >= 1024) {
|
||||||
|
return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s';
|
||||||
|
}
|
||||||
|
return '$bytesPerSecond B/s';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:moonwell_launcher/app/home_screen/home_screen.dart';
|
||||||
|
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
|
||||||
|
import 'package:moonwell_launcher/service_container.dart';
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
class LoginScreen extends StatefulWidget {
|
||||||
|
const LoginScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LoginScreen> createState() => _LoginScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
|
final _usernameController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_usernameController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _login() async {
|
||||||
|
final username = _usernameController.text.trim();
|
||||||
|
final password = _passwordController.text.trim();
|
||||||
|
|
||||||
|
if (username.isEmpty || password.isEmpty) {
|
||||||
|
setState(() => _error = 'Введите логин и пароль.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final api = getIt<LauncherApiClient>();
|
||||||
|
final session = await api.login(username: username, password: password);
|
||||||
|
final manifest = await api.fetchManifest(session.accessToken);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
await Navigator.of(context).pushReplacement(
|
||||||
|
PageRouteBuilder(
|
||||||
|
pageBuilder: (_, __, ___) =>
|
||||||
|
HomeScreen(session: session, manifest: manifest),
|
||||||
|
transitionsBuilder: (_, animation, __, child) {
|
||||||
|
return FadeTransition(opacity: animation, child: child);
|
||||||
|
},
|
||||||
|
transitionDuration: const Duration(milliseconds: 400),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_error = _formatError(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatError(Object error) {
|
||||||
|
final raw = error.toString();
|
||||||
|
if (raw.startsWith('Exception: ')) return raw.substring(11);
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
// Background
|
||||||
|
Positioned.fill(
|
||||||
|
child: Image.asset('assets/background.png', fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: RadialGradient(
|
||||||
|
center: Alignment.center,
|
||||||
|
radius: 1.0,
|
||||||
|
colors: [
|
||||||
|
MWColors.abyss.withAlpha(200),
|
||||||
|
MWColors.abyss.withAlpha(240),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Title bar
|
||||||
|
Positioned(top: 0, left: 0, right: 0, child: _TitleBar(cs: cs)),
|
||||||
|
// Login form — centered
|
||||||
|
Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 340,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Logo
|
||||||
|
Image.asset(
|
||||||
|
'assets/logo.png',
|
||||||
|
height: 160,
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
// Card
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(28),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: MWColors.abyss.withAlpha(210),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: cs.outline.withAlpha(80)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: MWColors.primary.withAlpha(30),
|
||||||
|
blurRadius: 40,
|
||||||
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
enabled: !_isLoading,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Логин',
|
||||||
|
prefixIcon: Icon(Icons.person_outline),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _passwordController,
|
||||||
|
enabled: !_isLoading,
|
||||||
|
obscureText: true,
|
||||||
|
onSubmitted: (_) => _login(),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Пароль',
|
||||||
|
prefixIcon: Icon(Icons.lock_outline),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _login,
|
||||||
|
child: _isLoading
|
||||||
|
? SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: cs.onPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('Войти'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: cs.error, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TitleBar extends StatelessWidget {
|
||||||
|
const _TitleBar({required this.cs});
|
||||||
|
final ColorScheme cs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onPanStart: (_) => windowManager.startDragging(),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 36,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Spacer(),
|
||||||
|
_TitleBarButton(
|
||||||
|
icon: Icons.remove,
|
||||||
|
onTap: () => windowManager.minimize(),
|
||||||
|
color: cs.onSurface,
|
||||||
|
),
|
||||||
|
_TitleBarButton(
|
||||||
|
icon: Icons.crop_square,
|
||||||
|
onTap: () async {
|
||||||
|
if (await windowManager.isMaximized()) {
|
||||||
|
await windowManager.unmaximize();
|
||||||
|
} else {
|
||||||
|
await windowManager.maximize();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
color: cs.onSurface,
|
||||||
|
),
|
||||||
|
_TitleBarButton(
|
||||||
|
icon: Icons.close,
|
||||||
|
onTap: () => SystemNavigator.pop(),
|
||||||
|
color: MWColors.error,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TitleBarButton extends StatelessWidget {
|
||||||
|
const _TitleBarButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.onTap,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 36,
|
||||||
|
child: Icon(icon, size: 16, color: color.withAlpha(180)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:moonwell_launcher/app/home_screen/home_screen.dart';
|
import 'package:moonwell_launcher/app/login_screen/login_screen.dart';
|
||||||
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
|
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
|
||||||
|
|
||||||
class MoonWellApp extends StatelessWidget {
|
class MoonWellApp extends StatelessWidget {
|
||||||
@@ -9,7 +9,7 @@ class MoonWellApp extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'MoonWell',
|
title: 'MoonWell',
|
||||||
home: const HomeScreen(),
|
home: const LoginScreen(),
|
||||||
theme: moonWellTheme(),
|
theme: moonWellTheme(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
part of 'mw_theme.dart';
|
part of 'mw_theme.dart';
|
||||||
|
|
||||||
/// Core palette derived from the logo
|
/// Core palette
|
||||||
class MWColors {
|
class MWColors {
|
||||||
// “Night sky” blues
|
// Surface & background
|
||||||
static const Color abyss = Color(0xFF0B101A); // page bg
|
static const Color abyss = Color(0xFF090F2B); // page bg
|
||||||
static const Color deepNavy = Color(0xFF0F1522); // surfaces
|
static const Color deepNavy = Color(0xFF111840); // surfaces
|
||||||
static const Color stormNavy = Color(0xFF1A2233); // elevated surfaces
|
static const Color stormNavy = Color(0xFF1A2759); // elevated surfaces
|
||||||
static const Color moonBlue = Color(
|
|
||||||
0xFF6BA3FF,
|
|
||||||
); // tertiary accent (moonlight)
|
|
||||||
|
|
||||||
// “Ornate gold”
|
// Primary & secondary
|
||||||
static const Color gold = Color(0xFFE5B74A); // primary
|
static const Color primary = Color(0xFF5460A2);
|
||||||
static const Color goldDark = Color(0xFF7A5A00); // primary container
|
static const Color secondary = Color(0xFF5F6BD2);
|
||||||
static const Color goldSoft = Color(0xFFF3D98C); // gradient highlight
|
static const Color tertiary = Color(0xFF6494EB);
|
||||||
|
|
||||||
// Lines & states
|
// Lines & states
|
||||||
static const Color outline = Color(0xFF2B3242);
|
static const Color outline = Color(0xFF4B4E6E);
|
||||||
static const Color outlineGold = Color(0xFF7C6A3A);
|
|
||||||
|
|
||||||
// Semantic
|
// Semantic
|
||||||
static const Color success = Color(0xFF3DDC97);
|
static const Color success = Color(0xFF3DDC97);
|
||||||
static const Color warning = Color(0xFFF0B429);
|
static const Color warning = Color(0xFFF0B429);
|
||||||
|
static const Color error = Color(0xFFD86A8A);
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-39
@@ -4,40 +4,26 @@ part 'mw_colors.dart';
|
|||||||
part 'mw_decorations.dart';
|
part 'mw_decorations.dart';
|
||||||
|
|
||||||
ThemeData moonWellTheme() {
|
ThemeData moonWellTheme() {
|
||||||
const cs = ColorScheme(
|
const cs = ColorScheme.dark(
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
primary: MWColors.gold,
|
primary: Color(0xFF5460A2),
|
||||||
onPrimary: Color(0xFF1B1202),
|
onPrimary: Color(0xFFDDE0FB),
|
||||||
primaryContainer: MWColors.goldDark,
|
|
||||||
onPrimaryContainer: Color(0xFFFFF0C2),
|
|
||||||
|
|
||||||
secondary: Color(0xFFC08A2E), // bronze accent
|
secondary: Color(0xFF5F6BD2),
|
||||||
onSecondary: Color(0xFF201300),
|
onSecondary: Color(0xFFDDE0FB),
|
||||||
secondaryContainer: Color(0xFF3B2A00),
|
|
||||||
onSecondaryContainer: Color(0xFFF6E2B6),
|
|
||||||
|
|
||||||
tertiary: MWColors.moonBlue, // moon-glow accent
|
tertiary: Color(0xFF6494EB),
|
||||||
onTertiary: Color(0xFF081120),
|
onTertiary: Color(0xFF090F2B),
|
||||||
tertiaryContainer: Color(0xFF143A66),
|
|
||||||
onTertiaryContainer: Color(0xFFD9EBFF),
|
|
||||||
|
|
||||||
error: Color(0xFFFFB4AB),
|
error: Color(0xFFD86A8A),
|
||||||
onError: Color(0xFF690005),
|
onError: Color(0xFF090F2B),
|
||||||
errorContainer: Color(0xFF93000A),
|
|
||||||
onErrorContainer: Color(0xFFFFDAD6),
|
|
||||||
|
|
||||||
surface: MWColors.abyss,
|
surface: Color(0xFF1A2759),
|
||||||
onSurface: Color(0xFFE5EAF6),
|
onSurface: Color(0xFFDDE0FB),
|
||||||
surfaceContainerHighest: MWColors.stormNavy,
|
|
||||||
onSurfaceVariant: Color(0xFFC3C8D6),
|
|
||||||
|
|
||||||
outline: MWColors.outline,
|
outline: Color(0xFF4B4E6E),
|
||||||
outlineVariant: Color(0xFF40495C),
|
shadow: Color(0xFF000000),
|
||||||
shadow: Colors.black,
|
scrim: Color(0xCC000000),
|
||||||
scrim: Colors.black87,
|
|
||||||
inverseSurface: Color(0xFFE5EAF6),
|
|
||||||
onInverseSurface: Color(0xFF11151E),
|
|
||||||
inversePrimary: Color(0xFFF0D072),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final base = ThemeData(
|
final base = ThemeData(
|
||||||
@@ -88,7 +74,7 @@ ThemeData moonWellTheme() {
|
|||||||
side: BorderSide(color: MWColors.outline.withAlpha(150)),
|
side: BorderSide(color: MWColors.outline.withAlpha(150)),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
shadowColor: MWColors.moonBlue.withAlpha(63),
|
shadowColor: MWColors.tertiary.withAlpha(63),
|
||||||
),
|
),
|
||||||
|
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
@@ -103,16 +89,14 @@ ThemeData moonWellTheme() {
|
|||||||
TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Cinzel'),
|
TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Cinzel'),
|
||||||
),
|
),
|
||||||
elevation: const WidgetStatePropertyAll(6),
|
elevation: const WidgetStatePropertyAll(6),
|
||||||
shadowColor: WidgetStatePropertyAll(MWColors.moonBlue.withAlpha(89)),
|
shadowColor: WidgetStatePropertyAll(MWColors.tertiary.withAlpha(89)),
|
||||||
backgroundColor: WidgetStateProperty.resolveWith((states) {
|
backgroundColor: WidgetStateProperty.resolveWith((states) {
|
||||||
if (states.contains(WidgetState.disabled)) {
|
if (states.contains(WidgetState.disabled)) {
|
||||||
return MWColors.gold.withAlpha(115);
|
return MWColors.primary.withAlpha(115);
|
||||||
}
|
}
|
||||||
return cs.primary;
|
return cs.primary;
|
||||||
}),
|
}),
|
||||||
foregroundColor: const WidgetStatePropertyAll(
|
foregroundColor: const WidgetStatePropertyAll(Color(0xFFDDE0FB)),
|
||||||
Color(0xFF1B1202),
|
|
||||||
), // dark text on gold
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -136,7 +120,7 @@ ThemeData moonWellTheme() {
|
|||||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
style: ButtonStyle(
|
style: ButtonStyle(
|
||||||
side: WidgetStatePropertyAll(
|
side: WidgetStatePropertyAll(
|
||||||
BorderSide(color: MWColors.outlineGold.withAlpha(230)),
|
BorderSide(color: MWColors.outline.withAlpha(230)),
|
||||||
),
|
),
|
||||||
shape: WidgetStatePropertyAll(
|
shape: WidgetStatePropertyAll(
|
||||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
@@ -211,14 +195,14 @@ ThemeData moonWellTheme() {
|
|||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: <Color>[
|
colors: <Color>[
|
||||||
MWColors.goldSoft, // highlight
|
Color(0xFF7A85CC), // highlight
|
||||||
MWColors.gold, // body
|
Color(0xFF5460A2), // body
|
||||||
Color(0xFFC58A1E), // warm edge
|
Color(0xFF3E4880), // edge
|
||||||
],
|
],
|
||||||
stops: [0.0, 0.55, 1.0],
|
stops: [0.0, 0.55, 1.0],
|
||||||
),
|
),
|
||||||
textGlow: Shadow(
|
textGlow: Shadow(
|
||||||
color: Color(0x446BA3FF), // moon-glow
|
color: Color(0x446494EB),
|
||||||
blurRadius: 14,
|
blurRadius: 14,
|
||||||
offset: Offset(0, 0),
|
offset: Offset(0, 0),
|
||||||
),
|
),
|
||||||
|
|||||||
+12
-8
@@ -1,10 +1,14 @@
|
|||||||
final class Config {
|
final class Config {
|
||||||
static const host = 'storage.yandexcloud.net';
|
static const launcherApiBaseUrl = String.fromEnvironment(
|
||||||
static const bucketName = 'warcraft-client';
|
'MOONWELL_API_BASE_URL',
|
||||||
static const id = 'ajeoijp637jj3527jd96';
|
);
|
||||||
static const serviceAccountId = 'ajerr0qmscj6eof68buc';
|
static const gameExecutableName = 'Wow.exe';
|
||||||
static const createdAt = "2025-08-24T12:08:22.166354325Z";
|
static const cacheDirectoryName = 'Cache';
|
||||||
static const keyId = 'YCAJEwIWaVVRCAx2YM2XMqcCS';
|
static const ignoredVerificationDirectories = <String>{
|
||||||
static const secret = '';
|
'cache',
|
||||||
static const objectName = 'World of Warcraft.zip';
|
'errors',
|
||||||
|
'logs',
|
||||||
|
'screenshots',
|
||||||
|
'wtf',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
+8
-5
@@ -214,7 +214,7 @@ class _LeftPane extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'Classic MMO Launcher',
|
'Classic MMO Launcher',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
color: cs.onSurface.withOpacity(0.85),
|
color: cs.onSurface.withValues(alpha: 0.85),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
@@ -289,7 +289,7 @@ class _LeftPane extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cs.surfaceVariant.withOpacity(0.5),
|
color: cs.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: cs.outline),
|
border: Border.all(color: cs.outline),
|
||||||
boxShadow: (deco.cardGlow),
|
boxShadow: (deco.cardGlow),
|
||||||
@@ -332,7 +332,7 @@ class _GildedProgressBar extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
height: 20,
|
height: 20,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cs.surfaceVariant,
|
color: cs.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(color: cs.outline),
|
border: Border.all(color: cs.outline),
|
||||||
),
|
),
|
||||||
@@ -352,7 +352,10 @@ class _GildedProgressBar extends StatelessWidget {
|
|||||||
// Subtle top highlight
|
// Subtle top highlight
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: Container(height: 6, color: Colors.white.withOpacity(0.08)),
|
child: Container(
|
||||||
|
height: 6,
|
||||||
|
color: Colors.white.withValues(alpha: 0.08),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -446,7 +449,7 @@ class _PatchNotesPane extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
s,
|
s,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: cs.onSurface.withOpacity(0.9),
|
color: cs.onSurface.withValues(alpha: 0.9),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:io';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:moonwell_launcher/features/downloader/application/download_manager.dart';
|
|
||||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_request.dart';
|
|
||||||
import 'package:moonwell_launcher/service_container.dart';
|
|
||||||
|
|
||||||
final _log = Logger('Downloader');
|
|
||||||
|
|
||||||
Future<void> main(List<String> arguments) async {
|
|
||||||
await configureDependencies();
|
|
||||||
_setupLogging();
|
|
||||||
|
|
||||||
final manager = getIt<DownloadManager>();
|
|
||||||
|
|
||||||
// Start the download and keep a handle to the subscription
|
|
||||||
final stream = manager.start(
|
|
||||||
id: 'client',
|
|
||||||
request: DownloadRequest(
|
|
||||||
destinationPath: '/Users/kalistratovm/Games/MoonWell/client.zip',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final done = Completer<int>(); // exit code completer
|
|
||||||
late StreamSubscription sub;
|
|
||||||
|
|
||||||
// Graceful shutdown on Ctrl-C / SIGTERM
|
|
||||||
final sigintSub = ProcessSignal.sigint.watch();
|
|
||||||
sigintSub.listen((_) async {
|
|
||||||
_log.warning('SIGINT received. Cancelling all downloads…');
|
|
||||||
await manager.cancelAll();
|
|
||||||
await sub.cancel();
|
|
||||||
if (!done.isCompleted) {
|
|
||||||
done.complete(130); // 130 = terminated by Ctrl-C
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
final sigtermSub = ProcessSignal.sigterm.watch().listen((_) async {
|
|
||||||
_log.warning('SIGTERM received. Cancelling all downloads…');
|
|
||||||
await manager.cancelAll();
|
|
||||||
await sub.cancel();
|
|
||||||
done.complete(143); // 143 = terminated by SIGTERM
|
|
||||||
});
|
|
||||||
|
|
||||||
sub = stream.listen(
|
|
||||||
(p) {
|
|
||||||
_log.info('Speed: ${p.speed}');
|
|
||||||
_log.info('ETA: ${p.eta}');
|
|
||||||
_log.info('Done: ${p.downloaded}/${p.total}');
|
|
||||||
},
|
|
||||||
onError: (e, st) async {
|
|
||||||
_log.severe('Download error', e, st);
|
|
||||||
await sigtermSub.cancel();
|
|
||||||
done.complete(1);
|
|
||||||
},
|
|
||||||
onDone: () async {
|
|
||||||
_log.info('Download completed');
|
|
||||||
await sigtermSub.cancel();
|
|
||||||
done.complete(0);
|
|
||||||
},
|
|
||||||
cancelOnError: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ⬇️ Keep process alive until one of the completions above fires
|
|
||||||
final code = await done.future;
|
|
||||||
exit(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _setupLogging() {
|
|
||||||
Logger.root.level = Level.INFO;
|
|
||||||
Logger.root.onRecord.listen((rec) {
|
|
||||||
final color = switch (rec.level) {
|
|
||||||
Level.SEVERE || Level.SHOUT => '\x1B[31m',
|
|
||||||
Level.WARNING => '\x1B[33m',
|
|
||||||
Level.INFO => '\x1B[36m',
|
|
||||||
_ => '\x1B[90m',
|
|
||||||
};
|
|
||||||
stdout.writeln(
|
|
||||||
'$color[${rec.time.toIso8601String()}] '
|
|
||||||
'${rec.level.name.padRight(7)} '
|
|
||||||
'${rec.loggerName}: ${rec.message}\x1B[0m',
|
|
||||||
);
|
|
||||||
if (rec.error != null) stdout.writeln(' Error: ${rec.error}');
|
|
||||||
if (rec.stackTrace != null) stdout.writeln(' Stack: ${rec.stackTrace}');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -11,20 +11,15 @@
|
|||||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
import 'package:get_it/get_it.dart' as _i174;
|
import 'package:get_it/get_it.dart' as _i174;
|
||||||
import 'package:injectable/injectable.dart' as _i526;
|
import 'package:injectable/injectable.dart' as _i526;
|
||||||
import 'package:minio/minio.dart' as _i875;
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart' as _i460;
|
import 'package:shared_preferences/shared_preferences.dart' as _i460;
|
||||||
|
|
||||||
import 'features/downloader/application/download_manager.dart' as _i535;
|
import 'features/launcher/application/client_sync_use_case.dart' as _i757;
|
||||||
import 'features/downloader/data/io_file_system.dart' as _i173;
|
import 'features/launcher/data/game_installation_service.dart' as _i315;
|
||||||
import 'features/downloader/data/minio_storage_reader.dart' as _i980;
|
import 'features/launcher/data/launcher_api_client.dart' as _i703;
|
||||||
import 'features/downloader/domain/repositories/file_system.dart' as _i843;
|
|
||||||
import 'features/downloader/domain/repositories/storage_reader.dart' as _i839;
|
|
||||||
import 'features/downloader/use_cases/download_object_use_case.dart' as _i925;
|
|
||||||
import 'features/preferences/data/repositories/shared_prefs_preferences_repository.dart'
|
import 'features/preferences/data/repositories/shared_prefs_preferences_repository.dart'
|
||||||
as _i662;
|
as _i662;
|
||||||
import 'features/preferences/domain/repositories/preferences_repository.dart'
|
import 'features/preferences/domain/repositories/preferences_repository.dart'
|
||||||
as _i44;
|
as _i44;
|
||||||
import 'third_party/minio.dart' as _i285;
|
|
||||||
import 'third_party/shared_preferences.dart' as _i1006;
|
import 'third_party/shared_preferences.dart' as _i1006;
|
||||||
|
|
||||||
const String _flutter = 'flutter';
|
const String _flutter = 'flutter';
|
||||||
@@ -37,35 +32,28 @@ extension GetItInjectableX on _i174.GetIt {
|
|||||||
}) async {
|
}) async {
|
||||||
final gh = _i526.GetItHelper(this, environment, environmentFilter);
|
final gh = _i526.GetItHelper(this, environment, environmentFilter);
|
||||||
final sharedPreferencesModule = _$SharedPreferencesModule();
|
final sharedPreferencesModule = _$SharedPreferencesModule();
|
||||||
final minioModule = _$MinioModule();
|
|
||||||
await gh.factoryAsync<_i460.SharedPreferences>(
|
await gh.factoryAsync<_i460.SharedPreferences>(
|
||||||
() => sharedPreferencesModule.prefs,
|
() => sharedPreferencesModule.prefs,
|
||||||
preResolve: true,
|
preResolve: true,
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i875.Minio>(() => minioModule.minioClient);
|
gh.lazySingleton<_i315.GameInstallationService>(
|
||||||
gh.lazySingleton<_i839.StorageReader>(
|
() => _i315.GameInstallationService(),
|
||||||
() => _i980.MinioStorageReader(gh<_i875.Minio>()),
|
);
|
||||||
|
gh.lazySingleton<_i703.LauncherApiClient>(() => _i703.LauncherApiClient());
|
||||||
|
gh.lazySingleton<_i757.ClientSyncUseCase>(
|
||||||
|
() => _i757.ClientSyncUseCase(
|
||||||
|
launcherApiClient: gh<_i703.LauncherApiClient>(),
|
||||||
|
installationService: gh<_i315.GameInstallationService>(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i843.FileSystem>(() => _i173.IoFileSystem());
|
|
||||||
gh.lazySingleton<_i44.PreferencesRepository>(
|
gh.lazySingleton<_i44.PreferencesRepository>(
|
||||||
() => _i662.SharedPrefsPreferencesRepository(
|
() => _i662.SharedPrefsPreferencesRepository(
|
||||||
preferences: gh<_i460.SharedPreferences>(),
|
preferences: gh<_i460.SharedPreferences>(),
|
||||||
),
|
),
|
||||||
registerFor: {_flutter},
|
registerFor: {_flutter},
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i925.DownloadObjectUseCase>(
|
|
||||||
() => _i925.DownloadObjectUseCase(
|
|
||||||
storage: gh<_i839.StorageReader>(),
|
|
||||||
fileSystem: gh<_i843.FileSystem>(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
gh.lazySingleton<_i535.DownloadManager>(
|
|
||||||
() => _i535.DownloadManager(gh<_i925.DownloadObjectUseCase>()),
|
|
||||||
);
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _$SharedPreferencesModule extends _i1006.SharedPreferencesModule {}
|
class _$SharedPreferencesModule extends _i1006.SharedPreferencesModule {}
|
||||||
|
|
||||||
class _$MinioModule extends _i285.MinioModule {}
|
|
||||||
|
|||||||
Vendored
-13
@@ -1,13 +0,0 @@
|
|||||||
import 'package:injectable/injectable.dart';
|
|
||||||
import 'package:minio/minio.dart';
|
|
||||||
import 'package:moonwell_launcher/config.dart';
|
|
||||||
|
|
||||||
@module
|
|
||||||
abstract class MinioModule {
|
|
||||||
@lazySingleton
|
|
||||||
Minio get minioClient => Minio(
|
|
||||||
endPoint: Config.host,
|
|
||||||
accessKey: Config.keyId,
|
|
||||||
secretKey: Config.secret,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+7
-47
@@ -18,7 +18,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "7.7.1"
|
version: "7.7.1"
|
||||||
args:
|
args:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: args
|
name: args
|
||||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
@@ -49,14 +49,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
buffer:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: buffer
|
|
||||||
sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.2.3"
|
|
||||||
build:
|
build:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -242,7 +234,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.4"
|
||||||
file:
|
file:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: file
|
name: file
|
||||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||||
@@ -336,14 +328,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
http:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: http
|
|
||||||
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.5.0"
|
|
||||||
http_multi_server:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -376,14 +360,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.8.1"
|
version: "2.8.1"
|
||||||
intl:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: intl
|
|
||||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.20.2"
|
|
||||||
io:
|
io:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -433,7 +409,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "5.1.1"
|
version: "5.1.1"
|
||||||
logging:
|
logging:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: logging
|
name: logging
|
||||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
@@ -460,10 +436,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -472,14 +448,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
minio:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: minio
|
|
||||||
sha256: ee2ce47766e46c7d164f960f2f5ed6a9a82844d877f6b82574f6876ec50c56d1
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.5.8"
|
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -600,14 +568,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.0"
|
version: "4.1.0"
|
||||||
rxdart:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: rxdart
|
|
||||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.28.0"
|
|
||||||
screen_retriever:
|
screen_retriever:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -785,10 +745,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.6"
|
version: "0.7.7"
|
||||||
timing:
|
timing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+1
-5
@@ -34,17 +34,12 @@ dependencies:
|
|||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
file: ^7.0.1
|
|
||||||
dio: ^5.9.0
|
dio: ^5.9.0
|
||||||
minio: ^3.5.8
|
|
||||||
args: ^2.7.0
|
|
||||||
logging: ^1.3.0
|
|
||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
injectable: ^2.5.1
|
injectable: ^2.5.1
|
||||||
crypto: ^3.0.6
|
crypto: ^3.0.6
|
||||||
get_it: ^8.2.0
|
get_it: ^8.2.0
|
||||||
flutter_bloc: ^9.1.1
|
flutter_bloc: ^9.1.1
|
||||||
rxdart: ^0.28.0
|
|
||||||
file_picker: ^10.3.2
|
file_picker: ^10.3.2
|
||||||
pixelarticons: ^0.4.0
|
pixelarticons: ^0.4.0
|
||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
@@ -76,6 +71,7 @@ flutter:
|
|||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
assets:
|
assets:
|
||||||
- assets/background.png
|
- assets/background.png
|
||||||
|
- assets/logo.png
|
||||||
# - images/a_dot_ham.jpeg
|
# - images/a_dot_ham.jpeg
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
|
|||||||
Reference in New Issue
Block a user