diff --git a/.vscode/launch.json b/.vscode/launch.json index f27ce44..9002b7a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,19 +7,28 @@ { "name": "moonwell_launcher", "request": "launch", - "type": "dart" + "type": "dart", + "toolArgs": [ + "--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online" + ] }, { "name": "moonwell_launcher (profile mode)", "request": "launch", "type": "dart", - "flutterMode": "profile" + "flutterMode": "profile", + "toolArgs": [ + "--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online" + ] }, { "name": "moonwell_launcher (release mode)", "request": "launch", "type": "dart", - "flutterMode": "release" + "flutterMode": "release", + "toolArgs": [ + "--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online" + ] } ] -} \ No newline at end of file +} diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..d0e610b Binary files /dev/null and b/assets/logo.png differ diff --git a/lib/app/home_screen/bloc/home_screen_bloc.dart b/lib/app/home_screen/bloc/home_screen_bloc.dart index 6aaaec9..060cc74 100644 --- a/lib/app/home_screen/bloc/home_screen_bloc.dart +++ b/lib/app/home_screen/bloc/home_screen_bloc.dart @@ -4,128 +4,365 @@ 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/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_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:rxdart/rxdart.dart'; part 'home_screen_event.dart'; part 'home_screen_state.dart'; -const _downloadId = 'client'; - class HomeScreenBloc extends Bloc { - final DownloadManager _downloadManager; - final PreferencesRepository _preferencesRepository; - - StreamSubscription? _downloadProgressSubscription; - HomeScreenBloc({ - required DownloadManager downloadManager, + required ClientSyncUseCase clientSyncUseCase, + required GameInstallationService gameInstallationService, required PreferencesRepository preferencesRepository, - }) : _downloadManager = downloadManager, + required LauncherSession session, + required ClientManifest manifest, + }) : _clientSyncUseCase = clientSyncUseCase, + _gameInstallationService = gameInstallationService, _preferencesRepository = preferencesRepository, - super(HomeScreenInitialState(model: HomeScreenModel.initial())) { - _setupHandlers(); + _session = session, + _manifest = manifest, + super(HomeScreenState( + model: HomeScreenModel.initial().copyWith( + isAuthenticated: true, + remoteBuildHash: manifest.buildHash, + ), + )) { + on(_onHomeScreenLoad); + on(_onHomeScreenSyncRequested); + on(_onHomeScreenOutputDirRequested); + on(_onHomeScreenPauseRequested); + on(_onHomeScreenPlayRequested); + on(_onHomeScreenSyncStatusChanged); + on(_onHomeScreenSyncFailed); add(HomeScreenLoad()); } - void _setupHandlers() { - on(_onHomeScreenLoad); - on(_onHomeScreenDownloadRequested); - on(_onHomeScreenDownloadPaused); - on(_onHomeOutputDirRequested); - on( - _onHomeScreenDownloadProgressUpdated, - transformer: (events, mapper) => - events.throttleTime(const Duration(seconds: 2)).switchMap(mapper), - ); - } + final ClientSyncUseCase _clientSyncUseCase; + final GameInstallationService _gameInstallationService; + final PreferencesRepository _preferencesRepository; + + StreamSubscription? _syncSubscription; + final LauncherSession _session; + final ClientManifest _manifest; + bool _pauseRequested = false; Future _onHomeScreenLoad( HomeScreenLoad event, Emitter emit, ) async { - final outputDir = await _preferencesRepository.getOutputDir(); + final outputPath = await _preferencesRepository.getOutputDir(); + final hasClientExecutable = await _resolveExecutablePresence(outputPath); emit( - HomeScreenReadyToDownloadState( - model: state.model.copyWith(outputPath: outputDir), + HomeScreenState( + model: state.model.copyWith( + outputPath: outputPath, + hasClientExecutable: hasClientExecutable, + phase: _stablePhase(hasClientExecutable), + statusText: _buildIdleStatus( + isAuthenticated: state.model.isAuthenticated, + outputPath: outputPath, + hasClientExecutable: hasClientExecutable, + ), + ), ), ); } - Future _onHomeScreenDownloadRequested( - HomeScreenDownloadRequested event, + Future _onHomeScreenSyncRequested( + HomeScreenSyncRequested event, Emitter emit, ) async { - if (state.model.outputPath == null) { - emit(HomeScreenOutputDirSelectionState(model: state.model.copyWith())); + if (state.model.isSyncing) { return; } - _downloadProgressSubscription = _downloadManager - .start( - id: _downloadId, - request: DownloadRequest( - destinationPath: state.model.outputPath!.toFilePath(), - ), - ) - .listen((progress) => add(HomeScreenDownloadProgressUpdated(progress))); + final outputPath = state.model.outputPath; + if (outputPath == null) { + add(HomeScreenOutputDirRequested()); + return; + } - emit(HomeScreenDownloadInitializing(model: state.model.copyWith())); - } + await _syncSubscription?.cancel(); + _pauseRequested = false; - void _onHomeScreenDownloadProgressUpdated( - HomeScreenDownloadProgressUpdated event, - Emitter emit, - ) { emit( - HomeScreenDownloadingState( - model: state.model.copyWith(progress: event.progress), + HomeScreenState( + 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 close() async { - await _downloadProgressSubscription?.cancel(); - await _downloadManager.cancel(_downloadId); - - return super.close(); - } - - FutureOr _onHomeScreenDownloadPaused( - HomeScreenDownloadPaused event, - Emitter emit, - ) async { - await _downloadManager.pause(_downloadId); - - emit(HomeScreenDownloadPausedState(model: state.model.copyWith())); - } - - FutureOr _onHomeOutputDirRequested( + Future _onHomeScreenOutputDirRequested( HomeScreenOutputDirRequested event, Emitter emit, ) async { - emit(HomeScreenReadyToDownloadState(model: state.model.copyWith())); + if (state.model.isSyncing) { + return; + } final directory = await FilePicker.platform.getDirectoryPath( - dialogTitle: 'Please select installation directory', + dialogTitle: 'Выберите папку установки Moonwell', ); if (directory == null) { return; } - await _preferencesRepository.setOutputDir(Uri.directory(directory)); + final outputPath = Uri.directory(directory); + await _preferencesRepository.setOutputDir(outputPath); + + final hasClientExecutable = await _resolveExecutablePresence(outputPath); emit( - HomeScreenReadyToDownloadState( - model: state.model.copyWith(outputPath: Uri.directory(directory)), + 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 _onHomeScreenPauseRequested( + HomeScreenPauseRequested event, + Emitter emit, + ) async { + if (!state.model.isSyncing) { + return; + } + + _pauseRequested = true; + + emit( + HomeScreenState( + model: state.model.copyWith( + statusText: 'Останавливаю обновление...', + errorMessage: null, + ), ), ); } + + Future _onHomeScreenPlayRequested( + HomeScreenPlayRequested event, + Emitter 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 _onHomeScreenSyncStatusChanged( + HomeScreenSyncStatusChanged event, + Emitter 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 _onHomeScreenSyncFailed( + HomeScreenSyncFailed event, + Emitter 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 _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 close() async { + _pauseRequested = true; + await _syncSubscription?.cancel(); + return super.close(); + } } diff --git a/lib/app/home_screen/bloc/home_screen_event.dart b/lib/app/home_screen/bloc/home_screen_event.dart index 50a8b1d..277852b 100644 --- a/lib/app/home_screen/bloc/home_screen_event.dart +++ b/lib/app/home_screen/bloc/home_screen_event.dart @@ -5,14 +5,22 @@ sealed class HomeScreenEvent {} final class HomeScreenLoad extends HomeScreenEvent {} -final class HomeScreenDownloadRequested extends HomeScreenEvent {} +final class HomeScreenSyncRequested extends HomeScreenEvent {} final class HomeScreenOutputDirRequested extends HomeScreenEvent {} -final class HomeScreenDownloadPaused extends HomeScreenEvent {} +final class HomeScreenPauseRequested extends HomeScreenEvent {} -final class HomeScreenDownloadProgressUpdated extends HomeScreenEvent { - final DownloadProgress progress; +final class HomeScreenPlayRequested extends HomeScreenEvent {} - 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); } diff --git a/lib/app/home_screen/bloc/home_screen_model.dart b/lib/app/home_screen/bloc/home_screen_model.dart index cdb4820..5a9807e 100644 --- a/lib/app/home_screen/bloc/home_screen_model.dart +++ b/lib/app/home_screen/bloc/home_screen_model.dart @@ -1,24 +1,107 @@ import 'package:flutter/foundation.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart'; +enum HomeScreenPhase { + idle, + ready, + authenticating, + syncing, + paused, + readyToPlay, + failure, +} + @immutable final class HomeScreenModel { - /// The current download progress. + static const Object _sentinel = Object(); + final DownloadProgress progress; - - /// The output path for the downloaded file. 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() : 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( 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, ); } } diff --git a/lib/app/home_screen/bloc/home_screen_state.dart b/lib/app/home_screen/bloc/home_screen_state.dart index dff7cc3..56a60d8 100644 --- a/lib/app/home_screen/bloc/home_screen_state.dart +++ b/lib/app/home_screen/bloc/home_screen_state.dart @@ -1,36 +1,8 @@ part of 'home_screen_bloc.dart'; @immutable -sealed class HomeScreenState { +final class HomeScreenState { final HomeScreenModel 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}); -} diff --git a/lib/app/home_screen/home_screen.dart b/lib/app/home_screen/home_screen.dart index e386292..21829af 100644 --- a/lib/app/home_screen/home_screen.dart +++ b/lib/app/home_screen/home_screen.dart @@ -1,95 +1,129 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.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/home_screen_content.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/service_container.dart'; +import 'package:window_manager/window_manager.dart'; 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 Widget build(BuildContext context) { - final theme = Theme.of(context); - return BlocProvider( create: (context) => HomeScreenBloc( - downloadManager: getIt(), + clientSyncUseCase: getIt(), + gameInstallationService: getIt(), preferencesRepository: getIt(), + session: session, + manifest: manifest, ), - child: BlocConsumer( - listener: (context, state) { - if (state is HomeScreenOutputDirSelectionState) { - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text('Alert'), - content: Text('Please select client location.'), - actions: [ - ElevatedButton.icon( - icon: const Icon(Icons.folder_open), - onPressed: () => context.read().add( - HomeScreenOutputDirRequested(), - ), - label: Text('Select directory'), - ), - ], - ), - ); - } - }, - - 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, - ), - ), - ), - ], - ), - ], - ), - ), - ), + child: Scaffold( + body: Stack( + children: [ + // Background image + Positioned.fill( + child: Image.asset('assets/background.png', fit: BoxFit.cover), + ), + // Gradient overlay + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + MWColors.abyss.withAlpha(180), + MWColors.abyss.withAlpha(220), + MWColors.abyss.withAlpha(240), + ], + stops: const [0.0, 0.5, 1.0], ), ), - 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)), ), ); } diff --git a/lib/app/home_screen/home_screen_content.dart b/lib/app/home_screen/home_screen_content.dart index dabdbc0..1dee5b2 100644 --- a/lib/app/home_screen/home_screen_content.dart +++ b/lib/app/home_screen/home_screen_content.dart @@ -1,184 +1,455 @@ import 'package:flutter/material.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_model.dart'; import 'package:moonwell_launcher/app/home_screen/widgets/gilded_progress_bar.dart'; import 'package:moonwell_launcher/app/theme/mw_theme.dart'; import 'package:pixelarticons/pixel.dart'; 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().state; + final model = state.model; + final cs = Theme.of(context).colorScheme; - String _speedLabel(BuildContext context) { - final kbps = context.read().state.model.progress.speed; - - if (kbps <= 0) return '—'; - if (kbps >= 1024) { - return '${(kbps / 1024).toStringAsFixed(2)} MB/s'; - } - return '${kbps.toStringAsFixed(0)} kB/s'; + return Padding( + padding: const EdgeInsets.only(top: 36), + child: Column( + children: [ + // Main content area + Expanded( + child: Row( + 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 Widget build(BuildContext context) { 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()!; - final state = context.read().state; - - final percent = _resolvePercentage(context); - final eta = state.model.progress.eta; - - final isDownloading = state is HomeScreenDownloadingState; - final canPlay = state is HomeScreenReadyToPlayState; - - return Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - title, - const SizedBox(height: 16), - Text( - 'Classic+ Wrath of the Lich King experience', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: cs.onSurface.withAlpha(200), - ), + return Container( + margin: const EdgeInsets.only(right: 20, top: 8, bottom: 12), + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: MWColors.abyss.withAlpha(200), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: cs.outline.withAlpha(80)), + boxShadow: deco.cardGlow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Spacer(), + // Folder selector + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => context.read().add( + HomeScreenOutputDirRequested(), ), - const SizedBox(height: 28), - const Spacer(), - // 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), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), decoration: BoxDecoration( - color: cs.surfaceContainerHighest.withAlpha(128), + color: cs.surface.withAlpha(100), borderRadius: BorderRadius.circular(12), - border: Border.all(color: cs.outline), - boxShadow: (deco.cardGlow), + border: Border.all(color: cs.outline.withAlpha(80)), ), child: Row( children: [ - const Icon(Pixel.folder), - const SizedBox(width: 8), + Icon(Pixel.folder, size: 18, color: cs.onSurfaceVariant), + const SizedBox(width: 10), Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => context.read().add( - HomeScreenOutputDirRequested(), - ), - child: Text( - 'Install path: ${state.model.outputPath?.toFilePath() ?? '—'}', - overflow: TextOverflow.ellipsis, + child: Text( + model.outputPath?.toFilePath() ?? 'Выбрать папку', + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, ), ), ), - 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) { - final state = context.read().state; - - if (state.model.progress.total == 0) { - return '0.0'; - } - - final percentage = - state.model.progress.downloaded / state.model.progress.total; - - return (percentage * 100).clamp(0, 100).toStringAsFixed(1); - } - - void _onStart(BuildContext context) { - context.read().add(HomeScreenDownloadRequested()); - } - - void _onPause(BuildContext context) { - context.read().add(HomeScreenDownloadPaused()); - } - - void _onReset(BuildContext context) { - // context.read().add(HomeScreenDownloadReset()); + String _shortHash(String? hash) { + if (hash == null || hash.isEmpty) return '—'; + if (hash.length <= 12) return hash; + return '${hash.substring(0, 8)}...${hash.substring(hash.length - 4)}'; + } +} + +// --------------------------------------------------------------------------- +// Bottom bar (play button, progress, version) +// --------------------------------------------------------------------------- +class _BottomBar extends StatelessWidget { + const _BottomBar({required this.model, required this.cs}); + + final HomeScreenModel model; + final ColorScheme cs; + + @override + Widget build(BuildContext context) { + final progressValue = model.progress.total == 0 + ? 0.0 + : model.progress.downloaded / model.progress.total; + + 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(); + + if (model.canPlay) { + bloc.add(HomeScreenPlayRequested()); + return; + } + + bloc.add(HomeScreenSyncRequested()); + } + + void _onSecondaryAction(BuildContext context, HomeScreenModel model) { + final bloc = context.read(); + + 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'; } } diff --git a/lib/app/login_screen/login_screen.dart b/lib/app/login_screen/login_screen.dart new file mode 100644 index 0000000..d0636e6 --- /dev/null +++ b/lib/app/login_screen/login_screen.dart @@ -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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _isLoading = false; + String? _error; + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _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(); + 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)), + ), + ); + } +} diff --git a/lib/app/mw_app.dart b/lib/app/mw_app.dart index 486f90c..c2e0f2e 100644 --- a/lib/app/mw_app.dart +++ b/lib/app/mw_app.dart @@ -1,5 +1,5 @@ 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'; class MoonWellApp extends StatelessWidget { @@ -9,7 +9,7 @@ class MoonWellApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( title: 'MoonWell', - home: const HomeScreen(), + home: const LoginScreen(), theme: moonWellTheme(), ); } diff --git a/lib/app/theme/mw_colors.dart b/lib/app/theme/mw_colors.dart index f3c0a18..82c7fba 100644 --- a/lib/app/theme/mw_colors.dart +++ b/lib/app/theme/mw_colors.dart @@ -1,25 +1,22 @@ part of 'mw_theme.dart'; -/// Core palette derived from the logo +/// Core palette class MWColors { - // “Night sky” blues - static const Color abyss = Color(0xFF0B101A); // page bg - static const Color deepNavy = Color(0xFF0F1522); // surfaces - static const Color stormNavy = Color(0xFF1A2233); // elevated surfaces - static const Color moonBlue = Color( - 0xFF6BA3FF, - ); // tertiary accent (moonlight) + // Surface & background + static const Color abyss = Color(0xFF090F2B); // page bg + static const Color deepNavy = Color(0xFF111840); // surfaces + static const Color stormNavy = Color(0xFF1A2759); // elevated surfaces - // “Ornate gold” - static const Color gold = Color(0xFFE5B74A); // primary - static const Color goldDark = Color(0xFF7A5A00); // primary container - static const Color goldSoft = Color(0xFFF3D98C); // gradient highlight + // Primary & secondary + static const Color primary = Color(0xFF5460A2); + static const Color secondary = Color(0xFF5F6BD2); + static const Color tertiary = Color(0xFF6494EB); // Lines & states - static const Color outline = Color(0xFF2B3242); - static const Color outlineGold = Color(0xFF7C6A3A); + static const Color outline = Color(0xFF4B4E6E); // Semantic static const Color success = Color(0xFF3DDC97); static const Color warning = Color(0xFFF0B429); + static const Color error = Color(0xFFD86A8A); } diff --git a/lib/app/theme/mw_theme.dart b/lib/app/theme/mw_theme.dart index 4427765..0a731be 100644 --- a/lib/app/theme/mw_theme.dart +++ b/lib/app/theme/mw_theme.dart @@ -4,40 +4,26 @@ part 'mw_colors.dart'; part 'mw_decorations.dart'; ThemeData moonWellTheme() { - const cs = ColorScheme( + const cs = ColorScheme.dark( brightness: Brightness.dark, - primary: MWColors.gold, - onPrimary: Color(0xFF1B1202), - primaryContainer: MWColors.goldDark, - onPrimaryContainer: Color(0xFFFFF0C2), + primary: Color(0xFF5460A2), + onPrimary: Color(0xFFDDE0FB), - secondary: Color(0xFFC08A2E), // bronze accent - onSecondary: Color(0xFF201300), - secondaryContainer: Color(0xFF3B2A00), - onSecondaryContainer: Color(0xFFF6E2B6), + secondary: Color(0xFF5F6BD2), + onSecondary: Color(0xFFDDE0FB), - tertiary: MWColors.moonBlue, // moon-glow accent - onTertiary: Color(0xFF081120), - tertiaryContainer: Color(0xFF143A66), - onTertiaryContainer: Color(0xFFD9EBFF), + tertiary: Color(0xFF6494EB), + onTertiary: Color(0xFF090F2B), - error: Color(0xFFFFB4AB), - onError: Color(0xFF690005), - errorContainer: Color(0xFF93000A), - onErrorContainer: Color(0xFFFFDAD6), + error: Color(0xFFD86A8A), + onError: Color(0xFF090F2B), - surface: MWColors.abyss, - onSurface: Color(0xFFE5EAF6), - surfaceContainerHighest: MWColors.stormNavy, - onSurfaceVariant: Color(0xFFC3C8D6), + surface: Color(0xFF1A2759), + onSurface: Color(0xFFDDE0FB), - outline: MWColors.outline, - outlineVariant: Color(0xFF40495C), - shadow: Colors.black, - scrim: Colors.black87, - inverseSurface: Color(0xFFE5EAF6), - onInverseSurface: Color(0xFF11151E), - inversePrimary: Color(0xFFF0D072), + outline: Color(0xFF4B4E6E), + shadow: Color(0xFF000000), + scrim: Color(0xCC000000), ); final base = ThemeData( @@ -88,7 +74,7 @@ ThemeData moonWellTheme() { side: BorderSide(color: MWColors.outline.withAlpha(150)), borderRadius: BorderRadius.circular(20), ), - shadowColor: MWColors.moonBlue.withAlpha(63), + shadowColor: MWColors.tertiary.withAlpha(63), ), elevatedButtonTheme: ElevatedButtonThemeData( @@ -103,16 +89,14 @@ ThemeData moonWellTheme() { TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Cinzel'), ), elevation: const WidgetStatePropertyAll(6), - shadowColor: WidgetStatePropertyAll(MWColors.moonBlue.withAlpha(89)), + shadowColor: WidgetStatePropertyAll(MWColors.tertiary.withAlpha(89)), backgroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) { - return MWColors.gold.withAlpha(115); + return MWColors.primary.withAlpha(115); } return cs.primary; }), - foregroundColor: const WidgetStatePropertyAll( - Color(0xFF1B1202), - ), // dark text on gold + foregroundColor: const WidgetStatePropertyAll(Color(0xFFDDE0FB)), ), ), @@ -136,7 +120,7 @@ ThemeData moonWellTheme() { outlinedButtonTheme: OutlinedButtonThemeData( style: ButtonStyle( side: WidgetStatePropertyAll( - BorderSide(color: MWColors.outlineGold.withAlpha(230)), + BorderSide(color: MWColors.outline.withAlpha(230)), ), shape: WidgetStatePropertyAll( RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), @@ -211,14 +195,14 @@ ThemeData moonWellTheme() { begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - MWColors.goldSoft, // highlight - MWColors.gold, // body - Color(0xFFC58A1E), // warm edge + Color(0xFF7A85CC), // highlight + Color(0xFF5460A2), // body + Color(0xFF3E4880), // edge ], stops: [0.0, 0.55, 1.0], ), textGlow: Shadow( - color: Color(0x446BA3FF), // moon-glow + color: Color(0x446494EB), blurRadius: 14, offset: Offset(0, 0), ), diff --git a/lib/config.dart b/lib/config.dart index 61a9b51..14b02fb 100644 --- a/lib/config.dart +++ b/lib/config.dart @@ -1,10 +1,14 @@ final class Config { - static const host = 'storage.yandexcloud.net'; - static const bucketName = 'warcraft-client'; - static const id = 'ajeoijp637jj3527jd96'; - static const serviceAccountId = 'ajerr0qmscj6eof68buc'; - static const createdAt = "2025-08-24T12:08:22.166354325Z"; - static const keyId = 'YCAJEwIWaVVRCAx2YM2XMqcCS'; - static const secret = ''; - static const objectName = 'World of Warcraft.zip'; + static const launcherApiBaseUrl = String.fromEnvironment( + 'MOONWELL_API_BASE_URL', + ); + static const gameExecutableName = 'Wow.exe'; + static const cacheDirectoryName = 'Cache'; + static const ignoredVerificationDirectories = { + 'cache', + 'errors', + 'logs', + 'screenshots', + 'wtf', + }; } diff --git a/lib/features/downloader/application/download_manager.dart b/lib/features/downloader/application/download_manager.dart deleted file mode 100644 index c7df20b..0000000 --- a/lib/features/downloader/application/download_manager.dart +++ /dev/null @@ -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.broadcast(); - - final DownloadId id; - final DownloadRequest request; - final DownloadObjectUseCase useCase; - - final StreamController progressController; - StreamSubscription? sub; - bool paused = false; - bool cancelled = false; - - Stream get stream => progressController.stream; - - Future 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 pause() async { - paused = true; - await sub?.cancel(); - sub = null; - } - - Future resume() async { - if (cancelled) return; - paused = false; - await start(); // will resume from file size - } - - Future 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 _sessions = {}; - - /// Start or replace a session with [id]. - Stream 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? progress(DownloadId id) => _sessions[id]?.stream; - - Future pause(DownloadId id) => _sessions[id]?.pause() ?? Future.value(); - Future resume(DownloadId id) => - _sessions[id]?.resume() ?? Future.value(); - Future cancel(DownloadId id) async { - await _sessions[id]?.cancel(); - _sessions.remove(id); - } - - bool isActive(DownloadId id) => _sessions.containsKey(id); - Iterable activeIds() => _sessions.keys; - - /// Cancel and clear all sessions (e.g., on sign-out). - Future cancelAll() async { - for (final s in _sessions.values) { - await s.cancel(); - } - _sessions.clear(); - } -} diff --git a/lib/features/downloader/data/io_file_system.dart b/lib/features/downloader/data/io_file_system.dart deleted file mode 100644 index accae65..0000000 --- a/lib/features/downloader/data/io_file_system.dart +++ /dev/null @@ -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 ensureParentExists(String path) => - File(path).parent.create(recursive: true); - - @override - Future exists(String path) => File(path).exists(); - - @override - Future sizeOf(String path) async => (await File(path).stat()).size; - - @override - Future truncate(String path) async => - File(path).writeAsBytes(const [], flush: true); - - @override - Future createEmpty(String path) => File(path).create(recursive: true); - - @override - Future>> openAppend(String path) async => - File(path).openWrite(mode: FileMode.append); - - @override - Future 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 _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; - } -} diff --git a/lib/features/downloader/data/minio_storage_reader.dart b/lib/features/downloader/data/minio_storage_reader.dart deleted file mode 100644 index 94944a3..0000000 --- a/lib/features/downloader/data/minio_storage_reader.dart +++ /dev/null @@ -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>> readFromOffset({ - required String bucket, - required String key, - required int startOffset, - }) { - return _client.getPartialObject(bucket, key, startOffset); - } - - @override - Future stat({ - required String bucket, - required String key, - }) async { - final s = await _client.statObject(bucket, key); - return RemoteObjectMeta(size: s.size ?? 0, eTag: s.etag ?? ''); - } -} diff --git a/lib/features/downloader/domain/entities/download_request.dart b/lib/features/downloader/domain/entities/download_request.dart deleted file mode 100644 index 6671548..0000000 --- a/lib/features/downloader/domain/entities/download_request.dart +++ /dev/null @@ -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}); -} diff --git a/lib/features/downloader/domain/entities/download_result.dart b/lib/features/downloader/domain/entities/download_result.dart deleted file mode 100644 index 7c926f5..0000000 --- a/lib/features/downloader/domain/entities/download_result.dart +++ /dev/null @@ -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, - }); -} diff --git a/lib/features/downloader/domain/entities/remote_object_meta.dart b/lib/features/downloader/domain/entities/remote_object_meta.dart deleted file mode 100644 index 6c46f98..0000000 --- a/lib/features/downloader/domain/entities/remote_object_meta.dart +++ /dev/null @@ -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}); -} diff --git a/lib/features/downloader/domain/repositories/file_system.dart b/lib/features/downloader/domain/repositories/file_system.dart deleted file mode 100644 index c969005..0000000 --- a/lib/features/downloader/domain/repositories/file_system.dart +++ /dev/null @@ -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 ensureParentExists(String path); - - /// Checks if a file or directory exists at the given path. - Future exists(String path); - - /// Returns the size of the file at the given path. - Future sizeOf(String path); - - /// Truncates the file at the given path to zero length. - Future truncate(String path); - - /// Creates an empty file at the given path. - Future createEmpty(String path); - - /// Opens a stream sink for appending data to the file at the given path. - Future>> openAppend(String path); - - /// Verifies the integrity of a file at the given path. - Future verifyFile( - String path, { - required String expected, - required String algo, - }); -} diff --git a/lib/features/downloader/domain/repositories/storage_reader.dart b/lib/features/downloader/domain/repositories/storage_reader.dart deleted file mode 100644 index 76347ff..0000000 --- a/lib/features/downloader/domain/repositories/storage_reader.dart +++ /dev/null @@ -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 stat({required String bucket, required String key}); - - /// Reads data from a remote object starting at the specified offset. - Future>> readFromOffset({ - required String bucket, - required String key, - required int startOffset, - }); -} diff --git a/lib/features/downloader/use_cases/download_object_use_case.dart b/lib/features/downloader/use_cases/download_object_use_case.dart deleted file mode 100644 index 94fb514..0000000 --- a/lib/features/downloader/use_cases/download_object_use_case.dart +++ /dev/null @@ -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 Function()? isCancelled; - - final void Function(DownloadResult result)? onComplete; - - const DownloadObjectUseCaseInput({ - required this.request, - this.isCancelled, - this.onComplete, - }); -} - -@lazySingleton -class DownloadObjectUseCase - implements StreamUseCase { - final StorageReader _storage; - - final FileSystem _fileSystem; - - const DownloadObjectUseCase({ - required StorageReader storage, - required FileSystem fileSystem, - }) : _storage = storage, - _fileSystem = fileSystem; - - @override - Stream 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(); - - 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> 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); - } -} diff --git a/lib/features/launcher/application/client_sync_use_case.dart b/lib/features/launcher/application/client_sync_use_case.dart new file mode 100644 index 0000000..67dcbcc --- /dev/null +++ b/lib/features/launcher/application/client_sync_use_case.dart @@ -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 Function()? isCancelled; + + const ClientSyncUseCaseInput({required this.request, this.isCancelled}); +} + +@lazySingleton +class ClientSyncUseCase + implements StreamUseCase { + ClientSyncUseCase({ + required LauncherApiClient launcherApiClient, + required GameInstallationService installationService, + }) : _launcherApiClient = launcherApiClient, + _installationService = installationService; + + final LauncherApiClient _launcherApiClient; + final GameInstallationService _installationService; + + @override + Stream call(ClientSyncUseCaseInput input) { + final controller = StreamController(); + unawaited(_run(input, controller)); + return controller.stream; + } + + Future _run( + ClientSyncUseCaseInput input, + StreamController 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 _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 _resolveStaleFiles( + ClientManifest manifest, + ClientInstallationSnapshot snapshot, + ) { + final remoteFiles = manifest.filesByPath; + + return snapshot.files + .where((localFile) => !remoteFiles.containsKey(localFile.path)) + .map((file) => file.path) + .toList(); + } + + Future _removeStaleFiles({ + required StreamController controller, + required String installationDir, + required List staleFiles, + required String remoteBuildHash, + required FutureOr 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 _downloadChangedFiles({ + required StreamController controller, + required ClientManifest manifest, + required String accessToken, + required String installationDir, + required List filesToDownload, + required String remoteBuildHash, + required FutureOr Function()? isCancelled, + }) async { + final totalBytes = filesToDownload.fold( + 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 controller, + ClientSyncStatus status, + ) { + if (!controller.isClosed) { + controller.add(status); + } + } + + Future _throwIfCancelled(FutureOr Function()? isCancelled) async { + if (isCancelled != null && await isCancelled()) { + throw const CancelledException(); + } + } +} diff --git a/lib/features/launcher/data/game_installation_service.dart b/lib/features/launcher/data/game_installation_service.dart new file mode 100644 index 0000000..b365970 --- /dev/null +++ b/lib/features/launcher/data/game_installation_service.dart @@ -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 scanInstallation( + String installationDir, { + InstallationScanProgressCallback? onProgress, + FutureOr 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( + 0, + (sum, pendingFile) => sum + pendingFile.size, + ); + + final files = []; + 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 computeSha256(String filePath) async { + final digest = await sha256.bind(File(filePath).openRead()).first; + return digest.toString(); + } + + Future 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 deleteRelativeFile( + String installationDir, + String relativePath, + ) async { + final file = File(resolveClientPath(installationDir, relativePath)); + if (await file.exists()) { + await file.delete(); + } + } + + Future 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 deleteFileIfExists(String filePath) async { + final file = File(filePath); + if (await file.exists()) { + await file.delete(); + } + } + + Future 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 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 [], + workingDirectory: installationDir, + mode: ProcessStartMode.detached, + ); + } + + Future 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 _collectFiles( + Directory directory, + String rootPath, + List<_PendingFile> pendingFiles, { + FutureOr 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 _throwIfCancelled(FutureOr 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, + }); +} diff --git a/lib/features/launcher/data/launcher_api_client.dart b/lib/features/launcher/data/launcher_api_client.dart new file mode 100644 index 0000000..0162d57 --- /dev/null +++ b/lib/features/launcher/data/launcher_api_client.dart @@ -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 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 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 downloadFile({ + required String accessToken, + required ClientManifestFile file, + required String destinationPath, + required FutureOr Function()? isCancelled, + required void Function(int chunkBytes) onChunkReceived, + }) async { + try { + final response = await _dio.getUri( + _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? headers, + }) { + return Options( + responseType: responseType, + headers: { + HttpHeaders.authorizationHeader: 'Bearer $accessToken', + ...?headers, + }, + ); + } + + Uri _resolveUri(String path) { + final rawBaseUrl = Config.launcherApiBaseUrl.trim(); + if (rawBaseUrl.isEmpty) { + throw const LauncherConfigurationException( + 'Set MOONWELL_API_BASE_URL before using the launcher API.', + ); + } + + final baseUri = Uri.parse( + rawBaseUrl.endsWith('/') ? rawBaseUrl : '$rawBaseUrl/', + ); + + if (!baseUri.hasScheme || baseUri.host.isEmpty) { + throw const LauncherConfigurationException( + 'MOONWELL_API_BASE_URL must be an absolute URL.', + ); + } + + return baseUri.resolve(path); + } + + Map _coerceMap(Object? data) { + if (data is Map) { + return data; + } + + if (data is Map) { + return data.map((key, value) => MapEntry(key.toString(), value)); + } + + throw const LauncherApiException('Unexpected API response payload.'); + } + + String _extractErrorMessage(DioException error) { + final data = error.response?.data; + + if (data is Map) { + final message = data['message']; + if (message is String && message.trim().isNotEmpty) { + return message; + } + } + + if (data is Map) { + final message = data['message']; + if (message is String && message.trim().isNotEmpty) { + return message; + } + } + + if (data is String && data.trim().isNotEmpty) { + return data; + } + + if (error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.receiveTimeout || + error.type == DioExceptionType.sendTimeout || + error.error is SocketException) { + return 'Unable to reach the launcher API.'; + } + + return error.message ?? 'Launcher API request failed.'; + } +} diff --git a/lib/features/launcher/domain/entities/client_hash_entry.dart b/lib/features/launcher/domain/entities/client_hash_entry.dart new file mode 100644 index 0000000..e7c6cf9 --- /dev/null +++ b/lib/features/launcher/domain/entities/client_hash_entry.dart @@ -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 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(); +} diff --git a/lib/features/launcher/domain/entities/client_installation_snapshot.dart b/lib/features/launcher/domain/entities/client_installation_snapshot.dart new file mode 100644 index 0000000..f4e39b6 --- /dev/null +++ b/lib/features/launcher/domain/entities/client_installation_snapshot.dart @@ -0,0 +1,23 @@ +import 'client_hash_entry.dart'; +import 'local_client_file.dart'; + +final class ClientInstallationSnapshot { + final List files; + final String buildHash; + + const ClientInstallationSnapshot({ + required this.files, + required this.buildHash, + }); + + factory ClientInstallationSnapshot.fromFiles(List files) { + return ClientInstallationSnapshot( + files: files, + buildHash: computeBuildHash(files), + ); + } + + Map get filesByPath => { + for (final file in files) normalizeClientPath(file.path): file, + }; +} diff --git a/lib/features/launcher/domain/entities/client_manifest.dart b/lib/features/launcher/domain/entities/client_manifest.dart new file mode 100644 index 0000000..f6ecde6 --- /dev/null +++ b/lib/features/launcher/domain/entities/client_manifest.dart @@ -0,0 +1,26 @@ +import 'client_hash_entry.dart'; +import 'client_manifest_file.dart'; + +final class ClientManifest { + final List files; + final String buildHash; + + const ClientManifest({required this.files, required this.buildHash}); + + factory ClientManifest.fromJson(Map json) { + final files = + ((json['files'] as List? ?? const []) + .whereType>() + .map(ClientManifestFile.fromJson) + .toList()) + .cast(); + + return ClientManifest(files: files, buildHash: computeBuildHash(files)); + } + + Map get filesByPath => { + for (final file in files) normalizeClientPath(file.path): file, + }; + + int get totalSize => files.fold(0, (sum, file) => sum + file.size); +} diff --git a/lib/features/launcher/domain/entities/client_manifest_file.dart b/lib/features/launcher/domain/entities/client_manifest_file.dart new file mode 100644 index 0000000..549ce11 --- /dev/null +++ b/lib/features/launcher/domain/entities/client_manifest_file.dart @@ -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 json) { + return ClientManifestFile( + path: normalizeClientPath(json['path'] as String? ?? ''), + size: (json['size'] as num? ?? 0).toInt(), + sha256: (json['sha256'] as String? ?? '').toLowerCase(), + ); + } +} diff --git a/lib/features/launcher/domain/entities/client_sync_status.dart b/lib/features/launcher/domain/entities/client_sync_status.dart new file mode 100644 index 0000000..6363be1 --- /dev/null +++ b/lib/features/launcher/domain/entities/client_sync_status.dart @@ -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, + }); +} diff --git a/lib/features/launcher/domain/entities/launcher_exception.dart b/lib/features/launcher/domain/entities/launcher_exception.dart new file mode 100644 index 0000000..dca9209 --- /dev/null +++ b/lib/features/launcher/domain/entities/launcher_exception.dart @@ -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); +} diff --git a/lib/features/launcher/domain/entities/launcher_session.dart b/lib/features/launcher/domain/entities/launcher_session.dart new file mode 100644 index 0000000..fcc3284 --- /dev/null +++ b/lib/features/launcher/domain/entities/launcher_session.dart @@ -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 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); + } +} diff --git a/lib/features/launcher/domain/entities/local_client_file.dart b/lib/features/launcher/domain/entities/local_client_file.dart new file mode 100644 index 0000000..91ead85 --- /dev/null +++ b/lib/features/launcher/domain/entities/local_client_file.dart @@ -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, + }); +} diff --git a/lib/main.dart b/lib/main.dart index f4f0397..8e61f53 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -214,7 +214,7 @@ class _LeftPane extends StatelessWidget { Text( 'Classic MMO Launcher', style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: cs.onSurface.withOpacity(0.85), + color: cs.onSurface.withValues(alpha: 0.85), ), ), const SizedBox(height: 28), @@ -289,7 +289,7 @@ class _LeftPane extends StatelessWidget { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: cs.surfaceVariant.withOpacity(0.5), + color: cs.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), border: Border.all(color: cs.outline), boxShadow: (deco.cardGlow), @@ -332,7 +332,7 @@ class _GildedProgressBar extends StatelessWidget { return Container( height: 20, decoration: BoxDecoration( - color: cs.surfaceVariant, + color: cs.surfaceContainerHighest, borderRadius: BorderRadius.circular(14), border: Border.all(color: cs.outline), ), @@ -352,7 +352,10 @@ class _GildedProgressBar extends StatelessWidget { // Subtle top highlight Align( 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( s, style: TextStyle( - color: cs.onSurface.withOpacity(0.9), + color: cs.onSurface.withValues(alpha: 0.9), ), ), ), diff --git a/lib/main_cli.dart b/lib/main_cli.dart deleted file mode 100644 index 5d78113..0000000 --- a/lib/main_cli.dart +++ /dev/null @@ -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 main(List arguments) async { - await configureDependencies(); - _setupLogging(); - - final manager = getIt(); - - // 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(); // 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}'); - }); -} diff --git a/lib/service_container.config.dart b/lib/service_container.config.dart index dc8b492..770be03 100644 --- a/lib/service_container.config.dart +++ b/lib/service_container.config.dart @@ -11,20 +11,15 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'package:get_it/get_it.dart' as _i174; import 'package:injectable/injectable.dart' as _i526; -import 'package:minio/minio.dart' as _i875; import 'package:shared_preferences/shared_preferences.dart' as _i460; -import 'features/downloader/application/download_manager.dart' as _i535; -import 'features/downloader/data/io_file_system.dart' as _i173; -import 'features/downloader/data/minio_storage_reader.dart' as _i980; -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/launcher/application/client_sync_use_case.dart' as _i757; +import 'features/launcher/data/game_installation_service.dart' as _i315; +import 'features/launcher/data/launcher_api_client.dart' as _i703; import 'features/preferences/data/repositories/shared_prefs_preferences_repository.dart' as _i662; import 'features/preferences/domain/repositories/preferences_repository.dart' as _i44; -import 'third_party/minio.dart' as _i285; import 'third_party/shared_preferences.dart' as _i1006; const String _flutter = 'flutter'; @@ -37,35 +32,28 @@ extension GetItInjectableX on _i174.GetIt { }) async { final gh = _i526.GetItHelper(this, environment, environmentFilter); final sharedPreferencesModule = _$SharedPreferencesModule(); - final minioModule = _$MinioModule(); await gh.factoryAsync<_i460.SharedPreferences>( () => sharedPreferencesModule.prefs, preResolve: true, ); - gh.lazySingleton<_i875.Minio>(() => minioModule.minioClient); - gh.lazySingleton<_i839.StorageReader>( - () => _i980.MinioStorageReader(gh<_i875.Minio>()), + gh.lazySingleton<_i315.GameInstallationService>( + () => _i315.GameInstallationService(), + ); + 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>( () => _i662.SharedPrefsPreferencesRepository( preferences: gh<_i460.SharedPreferences>(), ), 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; } } class _$SharedPreferencesModule extends _i1006.SharedPreferencesModule {} - -class _$MinioModule extends _i285.MinioModule {} diff --git a/lib/third_party/minio.dart b/lib/third_party/minio.dart deleted file mode 100644 index 40f0917..0000000 --- a/lib/third_party/minio.dart +++ /dev/null @@ -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, - ); -} diff --git a/pubspec.lock b/pubspec.lock index 7050ece..7e40176 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -18,7 +18,7 @@ packages: source: hosted version: "7.7.1" args: - dependency: "direct main" + dependency: transitive description: name: args sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 @@ -49,14 +49,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" - buffer: - dependency: transitive - description: - name: buffer - sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1" - url: "https://pub.dev" - source: hosted - version: "1.2.3" build: dependency: transitive description: @@ -242,7 +234,7 @@ packages: source: hosted version: "2.1.4" file: - dependency: "direct main" + dependency: transitive description: name: file sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 @@ -336,14 +328,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -376,14 +360,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.8.1" - intl: - dependency: transitive - description: - name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" - url: "https://pub.dev" - source: hosted - version: "0.20.2" io: dependency: transitive description: @@ -433,7 +409,7 @@ packages: source: hosted version: "5.1.1" logging: - dependency: "direct main" + dependency: transitive description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 @@ -460,10 +436,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -472,14 +448,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - minio: - dependency: "direct main" - description: - name: minio - sha256: ee2ce47766e46c7d164f960f2f5ed6a9a82844d877f6b82574f6876ec50c56d1 - url: "https://pub.dev" - source: hosted - version: "3.5.8" nested: dependency: transitive description: @@ -600,14 +568,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -785,10 +745,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3488969..8e78b12 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,17 +34,12 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - file: ^7.0.1 dio: ^5.9.0 - minio: ^3.5.8 - args: ^2.7.0 - logging: ^1.3.0 path: ^1.9.1 injectable: ^2.5.1 crypto: ^3.0.6 get_it: ^8.2.0 flutter_bloc: ^9.1.1 - rxdart: ^0.28.0 file_picker: ^10.3.2 pixelarticons: ^0.4.0 shared_preferences: ^2.5.3 @@ -76,6 +71,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - assets/background.png + - assets/logo.png # - images/a_dot_ham.jpeg # An image asset can refer to one or more resolution-specific "variants", see