613 lines
18 KiB
Dart
613 lines
18 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_model.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/application/client_sync_use_case.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_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';
|
|
|
|
part 'home_screen_event.dart';
|
|
part 'home_screen_state.dart';
|
|
|
|
class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|
HomeScreenBloc({
|
|
required ClientSyncUseCase clientSyncUseCase,
|
|
required GameInstallationService gameInstallationService,
|
|
required LauncherApiClient launcherApiClient,
|
|
required PreferencesRepository preferencesRepository,
|
|
required LauncherSession session,
|
|
required ClientManifest manifest,
|
|
Duration manifestRefreshInterval = const Duration(minutes: 5),
|
|
Duration realmRefreshInterval = const Duration(minutes: 1),
|
|
}) : _clientSyncUseCase = clientSyncUseCase,
|
|
_gameInstallationService = gameInstallationService,
|
|
_launcherApiClient = launcherApiClient,
|
|
_preferencesRepository = preferencesRepository,
|
|
_session = session,
|
|
_startupManifest = manifest,
|
|
super(
|
|
HomeScreenState(
|
|
model: HomeScreenModel.initial().copyWith(
|
|
isAuthenticated: true,
|
|
remoteBuildHash: manifest.buildHash,
|
|
),
|
|
),
|
|
) {
|
|
on<HomeScreenLoad>(_onHomeScreenLoad);
|
|
on<HomeScreenSyncRequested>(_onHomeScreenSyncRequested);
|
|
on<HomeScreenManifestRefreshRequested>(
|
|
_onHomeScreenManifestRefreshRequested,
|
|
);
|
|
on<HomeScreenRealmRefreshRequested>(_onHomeScreenRealmRefreshRequested);
|
|
on<HomeScreenAccountLoadRequested>(_onHomeScreenAccountLoadRequested);
|
|
on<HomeScreenOutputDirRequested>(_onHomeScreenOutputDirRequested);
|
|
on<HomeScreenPauseRequested>(_onHomeScreenPauseRequested);
|
|
on<HomeScreenPlayRequested>(_onHomeScreenPlayRequested);
|
|
on<HomeScreenGameExited>(_onHomeScreenGameExited);
|
|
on<HomeScreenLogoutRequested>(_onHomeScreenLogoutRequested);
|
|
on<HomeScreenSyncStatusChanged>(_onHomeScreenSyncStatusChanged);
|
|
on<HomeScreenSyncFailed>(_onHomeScreenSyncFailed);
|
|
|
|
add(HomeScreenLoad());
|
|
_manifestRefreshTimer = Timer.periodic(
|
|
manifestRefreshInterval,
|
|
(_) => add(HomeScreenManifestRefreshRequested()),
|
|
);
|
|
_realmRefreshTimer = Timer.periodic(
|
|
realmRefreshInterval,
|
|
(_) => add(HomeScreenRealmRefreshRequested()),
|
|
);
|
|
}
|
|
|
|
final ClientSyncUseCase _clientSyncUseCase;
|
|
final GameInstallationService _gameInstallationService;
|
|
final LauncherApiClient _launcherApiClient;
|
|
final PreferencesRepository _preferencesRepository;
|
|
|
|
StreamSubscription<ClientSyncStatus>? _syncSubscription;
|
|
final LauncherSession _session;
|
|
final ClientManifest _startupManifest;
|
|
Timer? _manifestRefreshTimer;
|
|
Timer? _realmRefreshTimer;
|
|
int? _activeGamePid;
|
|
bool _pauseRequested = false;
|
|
bool _gameLaunchPending = false;
|
|
|
|
Future<void> _onHomeScreenLoad(
|
|
HomeScreenLoad event,
|
|
Emitter<HomeScreenState> emit,
|
|
) async {
|
|
final outputPath = await _preferencesRepository.getOutputDir();
|
|
final hasClientExecutable = await _resolveExecutablePresence(outputPath);
|
|
|
|
emit(
|
|
HomeScreenState(
|
|
model: state.model.copyWith(
|
|
outputPath: outputPath,
|
|
hasClientExecutable: hasClientExecutable,
|
|
phase: _stablePhase(hasClientExecutable),
|
|
statusText: _buildIdleStatus(
|
|
isAuthenticated: state.model.isAuthenticated,
|
|
outputPath: outputPath,
|
|
hasClientExecutable: hasClientExecutable,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
if (outputPath != null) {
|
|
add(HomeScreenSyncRequested(manifest: _startupManifest));
|
|
}
|
|
add(HomeScreenRealmRefreshRequested());
|
|
add(HomeScreenAccountLoadRequested());
|
|
|
|
try {
|
|
final newsItems = await _launcherApiClient.fetchNews(
|
|
_session.accessToken,
|
|
);
|
|
emit(
|
|
HomeScreenState(
|
|
model: state.model.copyWith(
|
|
newsItems: newsItems,
|
|
isLoadingNews: false,
|
|
newsErrorMessage: null,
|
|
),
|
|
),
|
|
);
|
|
} catch (error) {
|
|
emit(
|
|
HomeScreenState(
|
|
model: state.model.copyWith(
|
|
isLoadingNews: false,
|
|
newsErrorMessage: _formatError(error),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _onHomeScreenAccountLoadRequested(
|
|
HomeScreenAccountLoadRequested event,
|
|
Emitter<HomeScreenState> emit,
|
|
) async {
|
|
if (!state.model.isAuthenticated) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final account = await _launcherApiClient.fetchAccount(
|
|
_session.accessToken,
|
|
);
|
|
if (!state.model.isAuthenticated) {
|
|
return;
|
|
}
|
|
emit(
|
|
HomeScreenState(
|
|
model: state.model.copyWith(
|
|
accountUsername: account.username.isEmpty ? null : account.username,
|
|
),
|
|
),
|
|
);
|
|
} catch (_) {
|
|
// Account metadata does not block patching or launching the game.
|
|
}
|
|
}
|
|
|
|
Future<void> _onHomeScreenRealmRefreshRequested(
|
|
HomeScreenRealmRefreshRequested event,
|
|
Emitter<HomeScreenState> emit,
|
|
) async {
|
|
if (!state.model.isAuthenticated) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final realms = await _launcherApiClient.fetchRealms();
|
|
if (!state.model.isAuthenticated) {
|
|
return;
|
|
}
|
|
emit(
|
|
HomeScreenState(
|
|
model: state.model.copyWith(
|
|
realm: realms.isEmpty ? null : realms.first,
|
|
),
|
|
),
|
|
);
|
|
} catch (_) {
|
|
// Keep the last known realm state and retry on the next timer tick.
|
|
}
|
|
}
|
|
|
|
Future<void> _onHomeScreenManifestRefreshRequested(
|
|
HomeScreenManifestRefreshRequested event,
|
|
Emitter<HomeScreenState> emit,
|
|
) async {
|
|
if (!state.model.isAuthenticated ||
|
|
state.model.isSyncing ||
|
|
state.model.isGameRunning ||
|
|
state.model.phase == HomeScreenPhase.paused ||
|
|
state.model.outputPath == null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final manifest = await _launcherApiClient.fetchManifest(
|
|
_session.accessToken,
|
|
);
|
|
|
|
if (state.model.isSyncing || !state.model.isAuthenticated) {
|
|
return;
|
|
}
|
|
|
|
if (manifest.buildHash == state.model.localBuildHash) {
|
|
if (manifest.buildHash != state.model.remoteBuildHash) {
|
|
emit(
|
|
HomeScreenState(
|
|
model: state.model.copyWith(remoteBuildHash: manifest.buildHash),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
add(HomeScreenSyncRequested(manifest: manifest));
|
|
} catch (_) {
|
|
// Retry on the next timer tick without replacing the stable UI state.
|
|
}
|
|
}
|
|
|
|
Future<void> _onHomeScreenSyncRequested(
|
|
HomeScreenSyncRequested event,
|
|
Emitter<HomeScreenState> emit,
|
|
) async {
|
|
if (state.model.isSyncing || state.model.isGameRunning) {
|
|
return;
|
|
}
|
|
|
|
final outputPath = state.model.outputPath;
|
|
if (outputPath == null) {
|
|
add(HomeScreenOutputDirRequested());
|
|
return;
|
|
}
|
|
|
|
await _syncSubscription?.cancel();
|
|
_pauseRequested = false;
|
|
|
|
emit(
|
|
HomeScreenState(
|
|
model: state.model.copyWith(
|
|
phase: HomeScreenPhase.syncing,
|
|
progress: const DownloadProgress.initial(),
|
|
statusText: 'Подготавливаю проверку клиента...',
|
|
currentPath: null,
|
|
errorMessage: null,
|
|
processedFiles: 0,
|
|
totalFiles: 0,
|
|
syncStage: null,
|
|
),
|
|
),
|
|
);
|
|
|
|
_syncSubscription = _clientSyncUseCase
|
|
.call(
|
|
ClientSyncUseCaseInput(
|
|
request: ClientSyncRequest(
|
|
installationDir: outputPath.toFilePath(),
|
|
accessToken: _session.accessToken,
|
|
manifest: event.manifest,
|
|
),
|
|
isCancelled: () async => _pauseRequested,
|
|
),
|
|
)
|
|
.listen(
|
|
(status) => add(HomeScreenSyncStatusChanged(status)),
|
|
onError: (error, _) => add(HomeScreenSyncFailed(error)),
|
|
);
|
|
}
|
|
|
|
Future<void> _onHomeScreenOutputDirRequested(
|
|
HomeScreenOutputDirRequested event,
|
|
Emitter<HomeScreenState> emit,
|
|
) async {
|
|
if (state.model.isSyncing) {
|
|
return;
|
|
}
|
|
|
|
final directory = await FilePicker.getDirectoryPath(
|
|
dialogTitle: 'Выберите папку установки Moonwell',
|
|
);
|
|
|
|
if (directory == null) {
|
|
return;
|
|
}
|
|
|
|
final outputPath = Uri.directory(directory);
|
|
await _preferencesRepository.setOutputDir(outputPath);
|
|
|
|
final hasClientExecutable = await _resolveExecutablePresence(outputPath);
|
|
|
|
emit(
|
|
HomeScreenState(
|
|
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 {
|
|
if (_gameLaunchPending || state.model.isGameRunning) {
|
|
return;
|
|
}
|
|
|
|
final outputPath = state.model.outputPath;
|
|
if (outputPath == null) {
|
|
emit(_buildErrorState('Сначала выберите папку установки.'));
|
|
return;
|
|
}
|
|
|
|
_gameLaunchPending = true;
|
|
try {
|
|
final installationDir = outputPath.toFilePath();
|
|
await _gameInstallationService.clearCache(installationDir);
|
|
final authorization = await _launcherApiClient.issueGameTicket(
|
|
_session.accessToken,
|
|
);
|
|
final process = await _gameInstallationService.launchGame(
|
|
installationDir,
|
|
authorization: authorization,
|
|
);
|
|
_activeGamePid = process.pid;
|
|
unawaited(
|
|
process.exitCode.then<void>(
|
|
(exitCode) {
|
|
if (!isClosed) {
|
|
add(HomeScreenGameExited(pid: process.pid, exitCode: exitCode));
|
|
}
|
|
},
|
|
onError: (_) {
|
|
if (!isClosed) {
|
|
add(HomeScreenGameExited(pid: process.pid, exitCode: null));
|
|
}
|
|
},
|
|
),
|
|
);
|
|
|
|
emit(
|
|
HomeScreenState(
|
|
model: state.model.copyWith(
|
|
phase: HomeScreenPhase.readyToPlay,
|
|
isGameRunning: true,
|
|
statusText: 'Игра запущена. Папка Cache очищена перед стартом.',
|
|
errorMessage: null,
|
|
),
|
|
),
|
|
);
|
|
} catch (error) {
|
|
emit(
|
|
_buildErrorState(
|
|
'Не удалось запустить игру.',
|
|
errorMessage: _formatError(error),
|
|
),
|
|
);
|
|
} finally {
|
|
_gameLaunchPending = false;
|
|
}
|
|
}
|
|
|
|
Future<void> _onHomeScreenGameExited(
|
|
HomeScreenGameExited event,
|
|
Emitter<HomeScreenState> emit,
|
|
) async {
|
|
if (_activeGamePid != event.pid) {
|
|
return;
|
|
}
|
|
|
|
_activeGamePid = null;
|
|
final hasClientExecutable = await _resolveExecutablePresence(
|
|
state.model.outputPath,
|
|
);
|
|
emit(
|
|
HomeScreenState(
|
|
model: state.model.copyWith(
|
|
phase: _stablePhase(hasClientExecutable),
|
|
hasClientExecutable: hasClientExecutable,
|
|
isGameRunning: false,
|
|
statusText: hasClientExecutable
|
|
? 'Игра закрыта. Клиент готов к запуску.'
|
|
: 'Игра закрыта, но Wow.exe больше не найден.',
|
|
errorMessage: null,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _onHomeScreenLogoutRequested(
|
|
HomeScreenLogoutRequested event,
|
|
Emitter<HomeScreenState> emit,
|
|
) async {
|
|
_pauseRequested = true;
|
|
_activeGamePid = null;
|
|
await _syncSubscription?.cancel();
|
|
_syncSubscription = null;
|
|
await _preferencesRepository.clearLauncherSession();
|
|
|
|
emit(
|
|
HomeScreenState(
|
|
model: state.model.copyWith(
|
|
phase: HomeScreenPhase.idle,
|
|
isAuthenticated: false,
|
|
isGameRunning: false,
|
|
accountUsername: null,
|
|
progress: const DownloadProgress.initial(),
|
|
statusText: 'Сессия завершена. Войдите снова.',
|
|
currentPath: null,
|
|
errorMessage: null,
|
|
localBuildHash: null,
|
|
remoteBuildHash: null,
|
|
processedFiles: 0,
|
|
totalFiles: 0,
|
|
newsItems: const [],
|
|
isLoadingNews: false,
|
|
newsErrorMessage: null,
|
|
realm: null,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
syncStage: event.status.stage,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
syncStage: null,
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
emit(
|
|
HomeScreenState(
|
|
model: state.model.copyWith(
|
|
phase: HomeScreenPhase.failure,
|
|
hasClientExecutable: hasClientExecutable,
|
|
statusText: 'Не удалось завершить обновление клиента.',
|
|
currentPath: null,
|
|
errorMessage: _formatError(event.error),
|
|
syncStage: null,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
_activeGamePid = null;
|
|
_manifestRefreshTimer?.cancel();
|
|
_realmRefreshTimer?.cancel();
|
|
await _syncSubscription?.cancel();
|
|
return super.close();
|
|
}
|
|
}
|