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

This commit is contained in:
2026-03-22 01:04:16 +04:00
parent 4b1cecbefd
commit 73d8798bb9
39 changed files with 2372 additions and 1017 deletions
+12 -3
View File
@@ -7,19 +7,28 @@
{ {
"name": "moonwell_launcher", "name": "moonwell_launcher",
"request": "launch", "request": "launch",
"type": "dart" "type": "dart",
"toolArgs": [
"--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online"
]
}, },
{ {
"name": "moonwell_launcher (profile mode)", "name": "moonwell_launcher (profile mode)",
"request": "launch", "request": "launch",
"type": "dart", "type": "dart",
"flutterMode": "profile" "flutterMode": "profile",
"toolArgs": [
"--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online"
]
}, },
{ {
"name": "moonwell_launcher (release mode)", "name": "moonwell_launcher (release mode)",
"request": "launch", "request": "launch",
"type": "dart", "type": "dart",
"flutterMode": "release" "flutterMode": "release",
"toolArgs": [
"--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online"
]
} }
] ]
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+308 -71
View File
@@ -4,128 +4,365 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_model.dart'; import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_model.dart';
import 'package:moonwell_launcher/features/downloader/application/download_manager.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_request.dart'; import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart';
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart'; import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
import 'package:rxdart/rxdart.dart';
part 'home_screen_event.dart'; part 'home_screen_event.dart';
part 'home_screen_state.dart'; part 'home_screen_state.dart';
const _downloadId = 'client';
class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> { class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
final DownloadManager _downloadManager;
final PreferencesRepository _preferencesRepository;
StreamSubscription<DownloadProgress>? _downloadProgressSubscription;
HomeScreenBloc({ HomeScreenBloc({
required DownloadManager downloadManager, required ClientSyncUseCase clientSyncUseCase,
required GameInstallationService gameInstallationService,
required PreferencesRepository preferencesRepository, required PreferencesRepository preferencesRepository,
}) : _downloadManager = downloadManager, required LauncherSession session,
required ClientManifest manifest,
}) : _clientSyncUseCase = clientSyncUseCase,
_gameInstallationService = gameInstallationService,
_preferencesRepository = preferencesRepository, _preferencesRepository = preferencesRepository,
super(HomeScreenInitialState(model: HomeScreenModel.initial())) { _session = session,
_setupHandlers(); _manifest = manifest,
super(HomeScreenState(
model: HomeScreenModel.initial().copyWith(
isAuthenticated: true,
remoteBuildHash: manifest.buildHash,
),
)) {
on<HomeScreenLoad>(_onHomeScreenLoad);
on<HomeScreenSyncRequested>(_onHomeScreenSyncRequested);
on<HomeScreenOutputDirRequested>(_onHomeScreenOutputDirRequested);
on<HomeScreenPauseRequested>(_onHomeScreenPauseRequested);
on<HomeScreenPlayRequested>(_onHomeScreenPlayRequested);
on<HomeScreenSyncStatusChanged>(_onHomeScreenSyncStatusChanged);
on<HomeScreenSyncFailed>(_onHomeScreenSyncFailed);
add(HomeScreenLoad()); add(HomeScreenLoad());
} }
void _setupHandlers() { final ClientSyncUseCase _clientSyncUseCase;
on<HomeScreenLoad>(_onHomeScreenLoad); final GameInstallationService _gameInstallationService;
on<HomeScreenDownloadRequested>(_onHomeScreenDownloadRequested); final PreferencesRepository _preferencesRepository;
on<HomeScreenDownloadPaused>(_onHomeScreenDownloadPaused);
on<HomeScreenOutputDirRequested>(_onHomeOutputDirRequested); StreamSubscription<ClientSyncStatus>? _syncSubscription;
on<HomeScreenDownloadProgressUpdated>( final LauncherSession _session;
_onHomeScreenDownloadProgressUpdated, final ClientManifest _manifest;
transformer: (events, mapper) => bool _pauseRequested = false;
events.throttleTime(const Duration(seconds: 2)).switchMap(mapper),
);
}
Future<void> _onHomeScreenLoad( Future<void> _onHomeScreenLoad(
HomeScreenLoad event, HomeScreenLoad event,
Emitter<HomeScreenState> emit, Emitter<HomeScreenState> emit,
) async { ) async {
final outputDir = await _preferencesRepository.getOutputDir(); final outputPath = await _preferencesRepository.getOutputDir();
final hasClientExecutable = await _resolveExecutablePresence(outputPath);
emit( emit(
HomeScreenReadyToDownloadState( HomeScreenState(
model: state.model.copyWith(outputPath: outputDir), model: state.model.copyWith(
outputPath: outputPath,
hasClientExecutable: hasClientExecutable,
phase: _stablePhase(hasClientExecutable),
statusText: _buildIdleStatus(
isAuthenticated: state.model.isAuthenticated,
outputPath: outputPath,
hasClientExecutable: hasClientExecutable,
),
),
), ),
); );
} }
Future<void> _onHomeScreenDownloadRequested( Future<void> _onHomeScreenSyncRequested(
HomeScreenDownloadRequested event, HomeScreenSyncRequested event,
Emitter<HomeScreenState> emit, Emitter<HomeScreenState> emit,
) async { ) async {
if (state.model.outputPath == null) { if (state.model.isSyncing) {
emit(HomeScreenOutputDirSelectionState(model: state.model.copyWith()));
return; return;
} }
_downloadProgressSubscription = _downloadManager final outputPath = state.model.outputPath;
.start( if (outputPath == null) {
id: _downloadId, add(HomeScreenOutputDirRequested());
request: DownloadRequest( return;
destinationPath: state.model.outputPath!.toFilePath(),
),
)
.listen((progress) => add(HomeScreenDownloadProgressUpdated(progress)));
emit(HomeScreenDownloadInitializing(model: state.model.copyWith()));
} }
void _onHomeScreenDownloadProgressUpdated( await _syncSubscription?.cancel();
HomeScreenDownloadProgressUpdated event, _pauseRequested = false;
Emitter<HomeScreenState> emit,
) {
emit( emit(
HomeScreenDownloadingState( HomeScreenState(
model: state.model.copyWith(progress: event.progress), model: state.model.copyWith(
phase: HomeScreenPhase.syncing,
progress: const DownloadProgress.initial(),
statusText: 'Подготавливаю проверку клиента...',
currentPath: null,
errorMessage: null,
processedFiles: 0,
totalFiles: 0,
), ),
),
);
_syncSubscription = _clientSyncUseCase
.call(
ClientSyncUseCaseInput(
request: ClientSyncRequest(
installationDir: outputPath.toFilePath(),
accessToken: _session.accessToken,
manifest: _manifest,
),
isCancelled: () async => _pauseRequested,
),
)
.listen(
(status) => add(HomeScreenSyncStatusChanged(status)),
onError: (error, _) => add(HomeScreenSyncFailed(error)),
); );
} }
@override Future<void> _onHomeScreenOutputDirRequested(
Future<void> close() async {
await _downloadProgressSubscription?.cancel();
await _downloadManager.cancel(_downloadId);
return super.close();
}
FutureOr<void> _onHomeScreenDownloadPaused(
HomeScreenDownloadPaused event,
Emitter<HomeScreenState> emit,
) async {
await _downloadManager.pause(_downloadId);
emit(HomeScreenDownloadPausedState(model: state.model.copyWith()));
}
FutureOr<void> _onHomeOutputDirRequested(
HomeScreenOutputDirRequested event, HomeScreenOutputDirRequested event,
Emitter<HomeScreenState> emit, Emitter<HomeScreenState> emit,
) async { ) async {
emit(HomeScreenReadyToDownloadState(model: state.model.copyWith())); if (state.model.isSyncing) {
return;
}
final directory = await FilePicker.platform.getDirectoryPath( final directory = await FilePicker.platform.getDirectoryPath(
dialogTitle: 'Please select installation directory', dialogTitle: 'Выберите папку установки Moonwell',
); );
if (directory == null) { if (directory == null) {
return; return;
} }
await _preferencesRepository.setOutputDir(Uri.directory(directory)); final outputPath = Uri.directory(directory);
await _preferencesRepository.setOutputDir(outputPath);
final hasClientExecutable = await _resolveExecutablePresence(outputPath);
emit( emit(
HomeScreenReadyToDownloadState( HomeScreenState(
model: state.model.copyWith(outputPath: Uri.directory(directory)), model: state.model.copyWith(
outputPath: outputPath,
hasClientExecutable: hasClientExecutable,
phase: _stablePhase(hasClientExecutable),
statusText: _buildIdleStatus(
isAuthenticated: state.model.isAuthenticated,
outputPath: outputPath,
hasClientExecutable: hasClientExecutable,
),
errorMessage: null,
),
),
);
add(HomeScreenSyncRequested());
}
Future<void> _onHomeScreenPauseRequested(
HomeScreenPauseRequested event,
Emitter<HomeScreenState> emit,
) async {
if (!state.model.isSyncing) {
return;
}
_pauseRequested = true;
emit(
HomeScreenState(
model: state.model.copyWith(
statusText: 'Останавливаю обновление...',
errorMessage: null,
),
), ),
); );
} }
Future<void> _onHomeScreenPlayRequested(
HomeScreenPlayRequested event,
Emitter<HomeScreenState> emit,
) async {
final outputPath = state.model.outputPath;
if (outputPath == null) {
emit(_buildErrorState('Сначала выберите папку установки.'));
return;
}
try {
final installationDir = outputPath.toFilePath();
await _gameInstallationService.clearCache(installationDir);
await _gameInstallationService.launchGame(installationDir);
emit(
HomeScreenState(
model: state.model.copyWith(
phase: HomeScreenPhase.readyToPlay,
statusText: 'Игра запущена. Папка Cache очищена перед стартом.',
errorMessage: null,
),
),
);
} catch (error) {
emit(
_buildErrorState(
'Не удалось запустить игру.',
errorMessage: _formatError(error),
),
);
}
}
Future<void> _onHomeScreenSyncStatusChanged(
HomeScreenSyncStatusChanged event,
Emitter<HomeScreenState> emit,
) async {
final outputPath = state.model.outputPath;
final isCompleted = event.status.stage == ClientSyncStage.completed;
final hasClientExecutable = isCompleted
? await _resolveExecutablePresence(outputPath)
: state.model.hasClientExecutable;
emit(
HomeScreenState(
model: state.model.copyWith(
progress: event.status.progress,
phase: isCompleted
? _stablePhase(hasClientExecutable)
: HomeScreenPhase.syncing,
hasClientExecutable: hasClientExecutable,
statusText: event.status.message,
currentPath: event.status.currentPath,
errorMessage: null,
localBuildHash: event.status.localBuildHash,
remoteBuildHash: event.status.remoteBuildHash,
processedFiles: event.status.processedFiles,
totalFiles: event.status.totalFiles,
),
),
);
}
Future<void> _onHomeScreenSyncFailed(
HomeScreenSyncFailed event,
Emitter<HomeScreenState> emit,
) async {
final hasClientExecutable = await _resolveExecutablePresence(
state.model.outputPath,
);
if (_pauseRequested || event.error is CancelledException) {
_pauseRequested = false;
emit(
HomeScreenState(
model: state.model.copyWith(
phase: HomeScreenPhase.paused,
hasClientExecutable: hasClientExecutable,
statusText: 'Обновление приостановлено.',
currentPath: null,
errorMessage: null,
),
),
);
return;
}
emit(
HomeScreenState(
model: state.model.copyWith(
phase: HomeScreenPhase.failure,
hasClientExecutable: hasClientExecutable,
statusText: 'Не удалось завершить обновление клиента.',
currentPath: null,
errorMessage: _formatError(event.error),
),
),
);
}
HomeScreenState _buildErrorState(String statusText, {String? errorMessage}) {
final hasClientExecutable = state.model.hasClientExecutable;
return HomeScreenState(
model: state.model.copyWith(
phase: hasClientExecutable
? HomeScreenPhase.readyToPlay
: HomeScreenPhase.failure,
statusText: statusText,
errorMessage: errorMessage ?? statusText,
),
);
}
HomeScreenPhase _stablePhase(bool hasClientExecutable) {
return hasClientExecutable
? HomeScreenPhase.readyToPlay
: HomeScreenPhase.ready;
}
Future<bool> _resolveExecutablePresence(Uri? outputPath) async {
if (outputPath == null) {
return false;
}
return _gameInstallationService.hasClientExecutable(
outputPath.toFilePath(),
);
}
String _buildIdleStatus({
required bool isAuthenticated,
required Uri? outputPath,
required bool hasClientExecutable,
}) {
if (hasClientExecutable && isAuthenticated) {
return 'Клиент найден. Можно играть или проверять обновления.';
}
if (hasClientExecutable) {
return 'Клиент найден. Можно играть или авторизоваться для проверки обновлений.';
}
if (outputPath == null && !isAuthenticated) {
return 'Выберите папку установки и авторизуйтесь.';
}
if (outputPath == null) {
return 'Авторизация выполнена. Выберите папку установки.';
}
if (isAuthenticated) {
return 'Папка выбрана. Можно устанавливать или обновлять клиент.';
}
return 'Папка выбрана. Авторизуйтесь для проверки файлов.';
}
String _formatError(Object error) {
if (error is LauncherException) {
return error.message;
}
if (error is DownloadException) {
return error.message;
}
final raw = error.toString();
return raw.startsWith('Exception: ') ? raw.substring(11) : raw;
}
@override
Future<void> close() async {
_pauseRequested = true;
await _syncSubscription?.cancel();
return super.close();
}
} }
@@ -5,14 +5,22 @@ sealed class HomeScreenEvent {}
final class HomeScreenLoad extends HomeScreenEvent {} final class HomeScreenLoad extends HomeScreenEvent {}
final class HomeScreenDownloadRequested extends HomeScreenEvent {} final class HomeScreenSyncRequested extends HomeScreenEvent {}
final class HomeScreenOutputDirRequested extends HomeScreenEvent {} final class HomeScreenOutputDirRequested extends HomeScreenEvent {}
final class HomeScreenDownloadPaused extends HomeScreenEvent {} final class HomeScreenPauseRequested extends HomeScreenEvent {}
final class HomeScreenDownloadProgressUpdated extends HomeScreenEvent { final class HomeScreenPlayRequested extends HomeScreenEvent {}
final DownloadProgress progress;
HomeScreenDownloadProgressUpdated(this.progress); final class HomeScreenSyncStatusChanged extends HomeScreenEvent {
final ClientSyncStatus status;
HomeScreenSyncStatusChanged(this.status);
}
final class HomeScreenSyncFailed extends HomeScreenEvent {
final Object error;
HomeScreenSyncFailed(this.error);
} }
@@ -1,24 +1,107 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
enum HomeScreenPhase {
idle,
ready,
authenticating,
syncing,
paused,
readyToPlay,
failure,
}
@immutable @immutable
final class HomeScreenModel { final class HomeScreenModel {
/// The current download progress. static const Object _sentinel = Object();
final DownloadProgress progress; final DownloadProgress progress;
/// The output path for the downloaded file.
final Uri? outputPath; final Uri? outputPath;
final HomeScreenPhase phase;
final bool isAuthenticated;
final bool hasClientExecutable;
final String statusText;
final String? currentPath;
final String? errorMessage;
final String? localBuildHash;
final String? remoteBuildHash;
final int processedFiles;
final int totalFiles;
const HomeScreenModel({required this.progress, this.outputPath}); const HomeScreenModel({
required this.progress,
required this.outputPath,
required this.phase,
required this.isAuthenticated,
required this.hasClientExecutable,
required this.statusText,
required this.currentPath,
required this.errorMessage,
required this.localBuildHash,
required this.remoteBuildHash,
required this.processedFiles,
required this.totalFiles,
});
const HomeScreenModel.initial() const HomeScreenModel.initial()
: progress = const DownloadProgress.initial(), : progress = const DownloadProgress.initial(),
outputPath = null; outputPath = null,
phase = HomeScreenPhase.idle,
isAuthenticated = false,
hasClientExecutable = false,
statusText = 'Загрузка...',
currentPath = null,
errorMessage = null,
localBuildHash = null,
remoteBuildHash = null,
processedFiles = 0,
totalFiles = 0;
HomeScreenModel copyWith({DownloadProgress? progress, Uri? outputPath}) { bool get isBusy =>
phase == HomeScreenPhase.authenticating ||
phase == HomeScreenPhase.syncing;
bool get isSyncing => phase == HomeScreenPhase.syncing;
bool get canPlay => hasClientExecutable && !isBusy;
HomeScreenModel copyWith({
DownloadProgress? progress,
Object? outputPath = _sentinel,
HomeScreenPhase? phase,
bool? isAuthenticated,
bool? hasClientExecutable,
String? statusText,
Object? currentPath = _sentinel,
Object? errorMessage = _sentinel,
Object? localBuildHash = _sentinel,
Object? remoteBuildHash = _sentinel,
int? processedFiles,
int? totalFiles,
}) {
return HomeScreenModel( return HomeScreenModel(
progress: progress ?? this.progress, progress: progress ?? this.progress,
outputPath: outputPath ?? this.outputPath, outputPath: outputPath == _sentinel
? this.outputPath
: outputPath as Uri?,
phase: phase ?? this.phase,
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
hasClientExecutable: hasClientExecutable ?? this.hasClientExecutable,
statusText: statusText ?? this.statusText,
currentPath: currentPath == _sentinel
? this.currentPath
: currentPath as String?,
errorMessage: errorMessage == _sentinel
? this.errorMessage
: errorMessage as String?,
localBuildHash: localBuildHash == _sentinel
? this.localBuildHash
: localBuildHash as String?,
remoteBuildHash: remoteBuildHash == _sentinel
? this.remoteBuildHash
: remoteBuildHash as String?,
processedFiles: processedFiles ?? this.processedFiles,
totalFiles: totalFiles ?? this.totalFiles,
); );
} }
} }
@@ -1,36 +1,8 @@
part of 'home_screen_bloc.dart'; part of 'home_screen_bloc.dart';
@immutable @immutable
sealed class HomeScreenState { final class HomeScreenState {
final HomeScreenModel model; final HomeScreenModel model;
const HomeScreenState({required this.model}); const HomeScreenState({required this.model});
} }
final class HomeScreenInitialState extends HomeScreenState {
const HomeScreenInitialState({required super.model});
}
final class HomeScreenReadyToDownloadState extends HomeScreenState {
const HomeScreenReadyToDownloadState({required super.model});
}
final class HomeScreenDownloadPausedState extends HomeScreenState {
const HomeScreenDownloadPausedState({required super.model});
}
final class HomeScreenDownloadInitializing extends HomeScreenState {
const HomeScreenDownloadInitializing({required super.model});
}
final class HomeScreenDownloadingState extends HomeScreenState {
const HomeScreenDownloadingState({required super.model});
}
final class HomeScreenOutputDirSelectionState extends HomeScreenState {
const HomeScreenOutputDirSelectionState({required super.model});
}
final class HomeScreenReadyToPlayState extends HomeScreenState {
const HomeScreenReadyToPlayState({required super.model});
}
+100 -66
View File
@@ -1,95 +1,129 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart'; import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart';
import 'package:moonwell_launcher/app/home_screen/home_screen_content.dart'; import 'package:moonwell_launcher/app/home_screen/home_screen_content.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart'; import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:moonwell_launcher/features/downloader/application/download_manager.dart'; import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart';
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart'; import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
import 'package:moonwell_launcher/service_container.dart'; import 'package:moonwell_launcher/service_container.dart';
import 'package:window_manager/window_manager.dart';
class HomeScreen extends StatelessWidget { class HomeScreen extends StatelessWidget {
const HomeScreen({super.key}); const HomeScreen({super.key, required this.session, required this.manifest});
final LauncherSession session;
final ClientManifest manifest;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocProvider<HomeScreenBloc>( return BlocProvider<HomeScreenBloc>(
create: (context) => HomeScreenBloc( create: (context) => HomeScreenBloc(
downloadManager: getIt<DownloadManager>(), clientSyncUseCase: getIt<ClientSyncUseCase>(),
gameInstallationService: getIt<GameInstallationService>(),
preferencesRepository: getIt<PreferencesRepository>(), preferencesRepository: getIt<PreferencesRepository>(),
session: session,
manifest: manifest,
), ),
child: BlocConsumer<HomeScreenBloc, HomeScreenState>( child: Scaffold(
listener: (context, state) {
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<HomeScreenBloc>().add(
HomeScreenOutputDirRequested(),
),
label: Text('Select directory'),
),
],
),
);
}
},
builder: (context, state) {
return Scaffold(
body: Stack( body: Stack(
children: [ children: [
// Background image
Positioned.fill( Positioned.fill(
child: Container( child: Image.asset('assets/background.png', fit: BoxFit.cover),
decoration: const BoxDecoration(
// subtle vignette for “night” vibe
gradient: RadialGradient(
center: Alignment(0, -0.5),
radius: 1.2,
colors: [MWColors.deepNavy, MWColors.abyss],
), ),
), // Gradient overlay
child: Center(
child: Padding(
padding: EdgeInsets.all(24.0),
child: Stack(
children: [
Positioned.fill( Positioned.fill(
child: Image.asset( child: DecoratedBox(
'assets/background.png', decoration: BoxDecoration(
fit: BoxFit.fitWidth, gradient: LinearGradient(
), begin: Alignment.topCenter,
), end: Alignment.bottomCenter,
Row( colors: [
children: [ MWColors.abyss.withAlpha(180),
Expanded( MWColors.abyss.withAlpha(220),
child: HomeScreenContent( MWColors.abyss.withAlpha(240),
title: Text( ],
'MoonWell', stops: const [0.0, 0.5, 1.0],
style: theme.textTheme.headlineLarge,
), ),
), ),
), ),
),
// Main content
const Positioned.fill(child: HomeScreenContent()),
// Custom title bar
const Positioned(top: 0, left: 0, right: 0, child: _TitleBar()),
], ],
), ),
], ),
), );
), }
), }
),
), class _TitleBar extends StatelessWidget {
if (state is HomeScreenDownloadInitializing) const _TitleBar();
Center(child: const CircularProgressIndicator()),
], @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)),
), ),
); );
} }
+402 -131
View File
@@ -1,184 +1,455 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart'; import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart';
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_model.dart';
import 'package:moonwell_launcher/app/home_screen/widgets/gilded_progress_bar.dart'; import 'package:moonwell_launcher/app/home_screen/widgets/gilded_progress_bar.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart'; import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:pixelarticons/pixel.dart'; import 'package:pixelarticons/pixel.dart';
class HomeScreenContent extends StatelessWidget { class HomeScreenContent extends StatelessWidget {
const HomeScreenContent({super.key, required this.title}); const HomeScreenContent({super.key});
final Widget title; @override
Widget build(BuildContext context) {
final state = context.watch<HomeScreenBloc>().state;
final model = state.model;
final cs = Theme.of(context).colorScheme;
String _speedLabel(BuildContext context) { return Padding(
final kbps = context.read<HomeScreenBloc>().state.model.progress.speed; padding: const EdgeInsets.only(top: 36),
child: Column(
if (kbps <= 0) return ''; children: [
if (kbps >= 1024) { // Main content area
return '${(kbps / 1024).toStringAsFixed(2)} MB/s'; Expanded(
child: Row(
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),
],
),
);
} }
return '${kbps.toStringAsFixed(0)} kB/s'; }
// ---------------------------------------------------------------------------
// News panel (left side)
// ---------------------------------------------------------------------------
class _NewsPanel extends StatelessWidget {
const _NewsPanel({required this.cs});
final ColorScheme cs;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 24, top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Logo
Center(
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Image.asset(
'assets/logo.png',
height: 120,
filterQuality: FilterQuality.high,
),
),
),
Text(
'Новости',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: cs.onSurface,
),
),
const SizedBox(height: 12),
// News list
Expanded(
child: ShaderMask(
shaderCallback: (bounds) => LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white,
Colors.white,
Colors.white.withAlpha(0),
],
stops: const [0.0, 0.85, 1.0],
).createShader(bounds),
blendMode: BlendMode.dstIn,
child: ListView(
padding: const EdgeInsets.only(right: 16, bottom: 24),
children: const [
_NewsCard(
title: 'Добро пожаловать в MoonWell!',
description:
'Мы рады приветствовать вас на нашем сервере. '
'Установите клиент, авторизуйтесь и окунитесь '
'в мир приключений!',
),
SizedBox(height: 10),
_NewsCard(
title: 'Обновление клиента',
description:
'Лаунчер автоматически проверит и загрузит '
'последние обновления игрового клиента при входе.',
),
],
),
),
),
],
),
);
} }
}
class _NewsCard extends StatelessWidget {
const _NewsCard({required this.title, required this.description});
final String title;
final String description;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final deco = Theme.of(context).extension<MoonWellDecorations>()!;
final state = context.read<HomeScreenBloc>().state; return Container(
padding: const EdgeInsets.all(16),
final percent = _resolvePercentage(context); decoration: BoxDecoration(
final eta = state.model.progress.eta; color: cs.surface.withAlpha(140),
borderRadius: BorderRadius.circular(16),
final isDownloading = state is HomeScreenDownloadingState; border: Border.all(color: cs.outline.withAlpha(100)),
final canPlay = state is HomeScreenReadyToPlayState; ),
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(
title, title,
const SizedBox(height: 16), style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: cs.tertiary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text( Text(
'Classic+ Wrath of the Lich King experience', description,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: cs.onSurface.withAlpha(200), color: cs.onSurface.withAlpha(200),
height: 1.4,
), ),
), ),
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), // ---------------------------------------------------------------------------
// Right panel (status & controls)
// ---------------------------------------------------------------------------
class _RightPanel extends StatelessWidget {
const _RightPanel({required this.model, required this.cs});
// Controls row final HomeScreenModel model;
Row( final ColorScheme cs;
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), @override
// Disk path / build info (placeholders) Widget build(BuildContext context) {
Container( final deco = Theme.of(context).extension<MoonWellDecorations>()!;
padding: const EdgeInsets.all(12),
return Container(
margin: const EdgeInsets.only(right: 20, top: 8, bottom: 12),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surfaceContainerHighest.withAlpha(128), color: MWColors.abyss.withAlpha(200),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(20),
border: Border.all(color: cs.outline), border: Border.all(color: cs.outline.withAlpha(80)),
boxShadow: (deco.cardGlow), boxShadow: deco.cardGlow,
), ),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Icon(Pixel.folder), const Spacer(),
const SizedBox(width: 8), // Folder selector
Expanded( GestureDetector(
child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () => context.read<HomeScreenBloc>().add( onTap: () => context.read<HomeScreenBloc>().add(
HomeScreenOutputDirRequested(), HomeScreenOutputDirRequested(),
), ),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: cs.surface.withAlpha(100),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline.withAlpha(80)),
),
child: Row(
children: [
Icon(Pixel.folder, size: 18, color: cs.onSurfaceVariant),
const SizedBox(width: 10),
Expanded(
child: Text( child: Text(
'Install path: ${state.model.outputPath?.toFilePath() ?? ''}', model.outputPath?.toFilePath() ?? 'Выбрать папку',
overflow: TextOverflow.ellipsis, 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) { String _shortHash(String? hash) {
final state = context.read<HomeScreenBloc>().state; if (hash == null || hash.isEmpty) return '';
if (hash.length <= 12) return hash;
if (state.model.progress.total == 0) { return '${hash.substring(0, 8)}...${hash.substring(hash.length - 4)}';
return '0.0'; }
} }
final percentage = // ---------------------------------------------------------------------------
state.model.progress.downloaded / state.model.progress.total; // Bottom bar (play button, progress, version)
// ---------------------------------------------------------------------------
return (percentage * 100).clamp(0, 100).toStringAsFixed(1); class _BottomBar extends StatelessWidget {
} const _BottomBar({required this.model, required this.cs});
void _onStart(BuildContext context) { final HomeScreenModel model;
context.read<HomeScreenBloc>().add(HomeScreenDownloadRequested()); final ColorScheme cs;
}
@override
void _onPause(BuildContext context) { Widget build(BuildContext context) {
context.read<HomeScreenBloc>().add(HomeScreenDownloadPaused()); final progressValue = model.progress.total == 0
} ? 0.0
: model.progress.downloaded / model.progress.total;
void _onReset(BuildContext context) {
// context.read<HomeScreenBloc>().add(HomeScreenDownloadReset()); return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
MWColors.abyss.withAlpha(0),
MWColors.abyss.withAlpha(230),
],
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Progress bar (only when syncing)
if (model.isSyncing) ...[
Row(
children: [
Expanded(child: GildedProgressBar(value: progressValue)),
const SizedBox(width: 12),
Text(
'${(progressValue * 100).clamp(0, 100).toStringAsFixed(1)}%',
style: TextStyle(
color: cs.onSurface,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 12),
Text(
_formatSpeed(model.progress.speed),
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
),
],
),
const SizedBox(height: 8),
],
// Bottom row: play + status
Row(
children: [
// Play button
SizedBox(
height: 44,
child: ElevatedButton.icon(
icon: Icon(_resolvePrimaryIcon(model), size: 20),
label: Text(_resolvePrimaryLabel(model)),
onPressed: model.isBusy
? null
: () => _onPrimaryAction(context, model),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 28),
),
),
),
if (model.canPlay || model.isSyncing) ...[
const SizedBox(width: 8),
SizedBox(
height: 44,
child: OutlinedButton.icon(
icon: Icon(_resolveSecondaryIcon(model), size: 18),
label: Text(_resolveSecondaryLabel(model)),
onPressed: () => _onSecondaryAction(context, model),
),
),
],
const SizedBox(width: 16),
// Status ticker
Expanded(
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: cs.surface.withAlpha(100),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline.withAlpha(60)),
),
alignment: Alignment.centerLeft,
child: Text(
model.currentPath ?? model.statusText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface.withAlpha(160),
fontSize: 12,
),
),
),
),
],
),
],
),
);
}
void _onPrimaryAction(BuildContext context, HomeScreenModel model) {
final bloc = context.read<HomeScreenBloc>();
if (model.canPlay) {
bloc.add(HomeScreenPlayRequested());
return;
}
bloc.add(HomeScreenSyncRequested());
}
void _onSecondaryAction(BuildContext context, HomeScreenModel model) {
final bloc = context.read<HomeScreenBloc>();
if (model.isSyncing) {
bloc.add(HomeScreenPauseRequested());
return;
}
if (model.canPlay) {
bloc.add(HomeScreenSyncRequested());
return;
}
bloc.add(HomeScreenOutputDirRequested());
}
String _resolvePrimaryLabel(HomeScreenModel model) {
if (model.isSyncing) return 'Обновление...';
if (model.canPlay) return 'Играть';
return model.hasClientExecutable ? 'Обновить' : 'Установить';
}
IconData _resolvePrimaryIcon(HomeScreenModel model) {
if (model.canPlay) return Icons.play_arrow_rounded;
return Icons.download_rounded;
}
String _resolveSecondaryLabel(HomeScreenModel model) {
if (model.isSyncing) return 'Пауза';
if (model.canPlay) return 'Проверить';
return 'Папка';
}
IconData _resolveSecondaryIcon(HomeScreenModel model) {
if (model.isSyncing) return Pixel.pause;
if (model.canPlay) return Icons.refresh_rounded;
return Pixel.folder;
}
String _formatSpeed(int bytesPerSecond) {
if (bytesPerSecond <= 0) return '';
if (bytesPerSecond >= 1024 * 1024) {
return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(2)} MB/s';
}
if (bytesPerSecond >= 1024) {
return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s';
}
return '$bytesPerSecond B/s';
} }
} }
+257
View File
@@ -0,0 +1,257 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:moonwell_launcher/app/home_screen/home_screen.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
import 'package:moonwell_launcher/service_container.dart';
import 'package:window_manager/window_manager.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
String? _error;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _login() async {
final username = _usernameController.text.trim();
final password = _passwordController.text.trim();
if (username.isEmpty || password.isEmpty) {
setState(() => _error = 'Введите логин и пароль.');
return;
}
setState(() {
_isLoading = true;
_error = null;
});
try {
final api = getIt<LauncherApiClient>();
final session = await api.login(username: username, password: password);
final manifest = await api.fetchManifest(session.accessToken);
if (!mounted) return;
await Navigator.of(context).pushReplacement(
PageRouteBuilder(
pageBuilder: (_, __, ___) =>
HomeScreen(session: session, manifest: manifest),
transitionsBuilder: (_, animation, __, child) {
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: const Duration(milliseconds: 400),
),
);
} catch (e) {
if (!mounted) return;
setState(() {
_isLoading = false;
_error = _formatError(e);
});
}
}
String _formatError(Object error) {
final raw = error.toString();
if (raw.startsWith('Exception: ')) return raw.substring(11);
return raw;
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
body: Stack(
children: [
// Background
Positioned.fill(
child: Image.asset('assets/background.png', fit: BoxFit.cover),
),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 1.0,
colors: [
MWColors.abyss.withAlpha(200),
MWColors.abyss.withAlpha(240),
],
),
),
),
),
// Title bar
Positioned(top: 0, left: 0, right: 0, child: _TitleBar(cs: cs)),
// Login form — centered
Center(
child: SizedBox(
width: 340,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo
Image.asset(
'assets/logo.png',
height: 160,
filterQuality: FilterQuality.high,
),
const SizedBox(height: 32),
// Card
Container(
padding: const EdgeInsets.all(28),
decoration: BoxDecoration(
color: MWColors.abyss.withAlpha(210),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: cs.outline.withAlpha(80)),
boxShadow: [
BoxShadow(
color: MWColors.primary.withAlpha(30),
blurRadius: 40,
spreadRadius: 2,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _usernameController,
enabled: !_isLoading,
decoration: const InputDecoration(
labelText: 'Логин',
prefixIcon: Icon(Icons.person_outline),
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
enabled: !_isLoading,
obscureText: true,
onSubmitted: (_) => _login(),
decoration: const InputDecoration(
labelText: 'Пароль',
prefixIcon: Icon(Icons.lock_outline),
),
textInputAction: TextInputAction.done,
),
const SizedBox(height: 24),
SizedBox(
height: 48,
child: ElevatedButton(
onPressed: _isLoading ? null : _login,
child: _isLoading
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimary,
),
)
: const Text('Войти'),
),
),
if (_error != null) ...[
const SizedBox(height: 16),
Text(
_error!,
textAlign: TextAlign.center,
style: TextStyle(color: cs.error, fontSize: 13),
),
],
],
),
),
],
),
),
),
],
),
);
}
}
class _TitleBar extends StatelessWidget {
const _TitleBar({required this.cs});
final ColorScheme cs;
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanStart: (_) => windowManager.startDragging(),
child: SizedBox(
height: 36,
child: Row(
children: [
const Spacer(),
_TitleBarButton(
icon: Icons.remove,
onTap: () => windowManager.minimize(),
color: cs.onSurface,
),
_TitleBarButton(
icon: Icons.crop_square,
onTap: () async {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
},
color: cs.onSurface,
),
_TitleBarButton(
icon: Icons.close,
onTap: () => SystemNavigator.pop(),
color: MWColors.error,
),
],
),
),
);
}
}
class _TitleBarButton extends StatelessWidget {
const _TitleBarButton({
required this.icon,
required this.onTap,
required this.color,
});
final IconData icon;
final VoidCallback onTap;
final Color color;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: SizedBox(
width: 40,
height: 36,
child: Icon(icon, size: 16, color: color.withAlpha(180)),
),
);
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/home_screen/home_screen.dart'; import 'package:moonwell_launcher/app/login_screen/login_screen.dart';
import 'package:moonwell_launcher/app/theme/mw_theme.dart'; import 'package:moonwell_launcher/app/theme/mw_theme.dart';
class MoonWellApp extends StatelessWidget { class MoonWellApp extends StatelessWidget {
@@ -9,7 +9,7 @@ class MoonWellApp extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'MoonWell', title: 'MoonWell',
home: const HomeScreen(), home: const LoginScreen(),
theme: moonWellTheme(), theme: moonWellTheme(),
); );
} }
+11 -14
View File
@@ -1,25 +1,22 @@
part of 'mw_theme.dart'; part of 'mw_theme.dart';
/// Core palette derived from the logo /// Core palette
class MWColors { class MWColors {
// “Night sky” blues // Surface & background
static const Color abyss = Color(0xFF0B101A); // page bg static const Color abyss = Color(0xFF090F2B); // page bg
static const Color deepNavy = Color(0xFF0F1522); // surfaces static const Color deepNavy = Color(0xFF111840); // surfaces
static const Color stormNavy = Color(0xFF1A2233); // elevated surfaces static const Color stormNavy = Color(0xFF1A2759); // elevated surfaces
static const Color moonBlue = Color(
0xFF6BA3FF,
); // tertiary accent (moonlight)
// “Ornate gold” // Primary & secondary
static const Color gold = Color(0xFFE5B74A); // primary static const Color primary = Color(0xFF5460A2);
static const Color goldDark = Color(0xFF7A5A00); // primary container static const Color secondary = Color(0xFF5F6BD2);
static const Color goldSoft = Color(0xFFF3D98C); // gradient highlight static const Color tertiary = Color(0xFF6494EB);
// Lines & states // Lines & states
static const Color outline = Color(0xFF2B3242); static const Color outline = Color(0xFF4B4E6E);
static const Color outlineGold = Color(0xFF7C6A3A);
// Semantic // Semantic
static const Color success = Color(0xFF3DDC97); static const Color success = Color(0xFF3DDC97);
static const Color warning = Color(0xFFF0B429); static const Color warning = Color(0xFFF0B429);
static const Color error = Color(0xFFD86A8A);
} }
+23 -39
View File
@@ -4,40 +4,26 @@ part 'mw_colors.dart';
part 'mw_decorations.dart'; part 'mw_decorations.dart';
ThemeData moonWellTheme() { ThemeData moonWellTheme() {
const cs = ColorScheme( const cs = ColorScheme.dark(
brightness: Brightness.dark, brightness: Brightness.dark,
primary: MWColors.gold, primary: Color(0xFF5460A2),
onPrimary: Color(0xFF1B1202), onPrimary: Color(0xFFDDE0FB),
primaryContainer: MWColors.goldDark,
onPrimaryContainer: Color(0xFFFFF0C2),
secondary: Color(0xFFC08A2E), // bronze accent secondary: Color(0xFF5F6BD2),
onSecondary: Color(0xFF201300), onSecondary: Color(0xFFDDE0FB),
secondaryContainer: Color(0xFF3B2A00),
onSecondaryContainer: Color(0xFFF6E2B6),
tertiary: MWColors.moonBlue, // moon-glow accent tertiary: Color(0xFF6494EB),
onTertiary: Color(0xFF081120), onTertiary: Color(0xFF090F2B),
tertiaryContainer: Color(0xFF143A66),
onTertiaryContainer: Color(0xFFD9EBFF),
error: Color(0xFFFFB4AB), error: Color(0xFFD86A8A),
onError: Color(0xFF690005), onError: Color(0xFF090F2B),
errorContainer: Color(0xFF93000A),
onErrorContainer: Color(0xFFFFDAD6),
surface: MWColors.abyss, surface: Color(0xFF1A2759),
onSurface: Color(0xFFE5EAF6), onSurface: Color(0xFFDDE0FB),
surfaceContainerHighest: MWColors.stormNavy,
onSurfaceVariant: Color(0xFFC3C8D6),
outline: MWColors.outline, outline: Color(0xFF4B4E6E),
outlineVariant: Color(0xFF40495C), shadow: Color(0xFF000000),
shadow: Colors.black, scrim: Color(0xCC000000),
scrim: Colors.black87,
inverseSurface: Color(0xFFE5EAF6),
onInverseSurface: Color(0xFF11151E),
inversePrimary: Color(0xFFF0D072),
); );
final base = ThemeData( final base = ThemeData(
@@ -88,7 +74,7 @@ ThemeData moonWellTheme() {
side: BorderSide(color: MWColors.outline.withAlpha(150)), side: BorderSide(color: MWColors.outline.withAlpha(150)),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
shadowColor: MWColors.moonBlue.withAlpha(63), shadowColor: MWColors.tertiary.withAlpha(63),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
@@ -103,16 +89,14 @@ ThemeData moonWellTheme() {
TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Cinzel'), TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Cinzel'),
), ),
elevation: const WidgetStatePropertyAll(6), elevation: const WidgetStatePropertyAll(6),
shadowColor: WidgetStatePropertyAll(MWColors.moonBlue.withAlpha(89)), shadowColor: WidgetStatePropertyAll(MWColors.tertiary.withAlpha(89)),
backgroundColor: WidgetStateProperty.resolveWith((states) { backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) { if (states.contains(WidgetState.disabled)) {
return MWColors.gold.withAlpha(115); return MWColors.primary.withAlpha(115);
} }
return cs.primary; return cs.primary;
}), }),
foregroundColor: const WidgetStatePropertyAll( foregroundColor: const WidgetStatePropertyAll(Color(0xFFDDE0FB)),
Color(0xFF1B1202),
), // dark text on gold
), ),
), ),
@@ -136,7 +120,7 @@ ThemeData moonWellTheme() {
outlinedButtonTheme: OutlinedButtonThemeData( outlinedButtonTheme: OutlinedButtonThemeData(
style: ButtonStyle( style: ButtonStyle(
side: WidgetStatePropertyAll( side: WidgetStatePropertyAll(
BorderSide(color: MWColors.outlineGold.withAlpha(230)), BorderSide(color: MWColors.outline.withAlpha(230)),
), ),
shape: WidgetStatePropertyAll( shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
@@ -211,14 +195,14 @@ ThemeData moonWellTheme() {
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: <Color>[ colors: <Color>[
MWColors.goldSoft, // highlight Color(0xFF7A85CC), // highlight
MWColors.gold, // body Color(0xFF5460A2), // body
Color(0xFFC58A1E), // warm edge Color(0xFF3E4880), // edge
], ],
stops: [0.0, 0.55, 1.0], stops: [0.0, 0.55, 1.0],
), ),
textGlow: Shadow( textGlow: Shadow(
color: Color(0x446BA3FF), // moon-glow color: Color(0x446494EB),
blurRadius: 14, blurRadius: 14,
offset: Offset(0, 0), offset: Offset(0, 0),
), ),
+12 -8
View File
@@ -1,10 +1,14 @@
final class Config { final class Config {
static const host = 'storage.yandexcloud.net'; static const launcherApiBaseUrl = String.fromEnvironment(
static const bucketName = 'warcraft-client'; 'MOONWELL_API_BASE_URL',
static const id = 'ajeoijp637jj3527jd96'; );
static const serviceAccountId = 'ajerr0qmscj6eof68buc'; static const gameExecutableName = 'Wow.exe';
static const createdAt = "2025-08-24T12:08:22.166354325Z"; static const cacheDirectoryName = 'Cache';
static const keyId = 'YCAJEwIWaVVRCAx2YM2XMqcCS'; static const ignoredVerificationDirectories = <String>{
static const secret = ''; 'cache',
static const objectName = 'World of Warcraft.zip'; 'errors',
'logs',
'screenshots',
'wtf',
};
} }
@@ -1,106 +0,0 @@
import 'dart:async';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_request.dart';
import 'package:moonwell_launcher/features/downloader/use_cases/download_object_use_case.dart';
typedef DownloadId = String;
class _Session {
_Session({required this.id, required this.request, required this.useCase})
: progressController = StreamController<DownloadProgress>.broadcast();
final DownloadId id;
final DownloadRequest request;
final DownloadObjectUseCase useCase;
final StreamController<DownloadProgress> progressController;
StreamSubscription<DownloadProgress>? sub;
bool paused = false;
bool cancelled = false;
Stream<DownloadProgress> get stream => progressController.stream;
Future<void> start() async {
cancelled = false;
paused = false;
sub = useCase
.call(
DownloadObjectUseCaseInput(
request: request,
isCancelled: () async => cancelled || paused,
),
)
.listen(
progressController.add,
onError: progressController.addError,
onDone: () => progressController.close(),
);
}
Future<void> pause() async {
paused = true;
await sub?.cancel();
sub = null;
}
Future<void> resume() async {
if (cancelled) return;
paused = false;
await start(); // will resume from file size
}
Future<void> cancel() async {
cancelled = true;
await sub?.cancel();
sub = null;
if (!progressController.isClosed) {
progressController.addError(const CancelledException());
await progressController.close();
}
}
}
@lazySingleton
class DownloadManager {
DownloadManager(this._useCase);
final DownloadObjectUseCase _useCase;
final Map<DownloadId, _Session> _sessions = {};
/// Start or replace a session with [id].
Stream<DownloadProgress> start({
required DownloadId id,
required DownloadRequest request,
}) {
// dispose old session if exists
_sessions[id]?.cancel();
final s = _Session(id: id, request: request, useCase: _useCase);
_sessions[id] = s;
unawaited(s.start());
return s.stream;
}
Stream<DownloadProgress>? progress(DownloadId id) => _sessions[id]?.stream;
Future<void> pause(DownloadId id) => _sessions[id]?.pause() ?? Future.value();
Future<void> resume(DownloadId id) =>
_sessions[id]?.resume() ?? Future.value();
Future<void> cancel(DownloadId id) async {
await _sessions[id]?.cancel();
_sessions.remove(id);
}
bool isActive(DownloadId id) => _sessions.containsKey(id);
Iterable<DownloadId> activeIds() => _sessions.keys;
/// Cancel and clear all sessions (e.g., on sign-out).
Future<void> cancelAll() async {
for (final s in _sessions.values) {
await s.cancel();
}
_sessions.clear();
}
}
@@ -1,64 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:crypto/crypto.dart' as c;
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/features/downloader/domain/repositories/file_system.dart';
@LazySingleton(as: FileSystem)
class IoFileSystem implements FileSystem {
@override
Future<void> ensureParentExists(String path) =>
File(path).parent.create(recursive: true);
@override
Future<bool> exists(String path) => File(path).exists();
@override
Future<int> sizeOf(String path) async => (await File(path).stat()).size;
@override
Future<void> truncate(String path) async =>
File(path).writeAsBytes(const [], flush: true);
@override
Future<void> createEmpty(String path) => File(path).create(recursive: true);
@override
Future<StreamSink<List<int>>> openAppend(String path) async =>
File(path).openWrite(mode: FileMode.append);
@override
Future<bool> verifyFile(
String path, {
required String expected,
required String algo,
}) async {
final file = File(path);
if (!await file.exists()) return false;
final digestHex = await _computeDigestHex(file, algo);
return _constantTimeEquals(digestHex.toLowerCase(), expected.toLowerCase());
}
Future<String> _computeDigestHex(File file, String algo) async {
final hash = switch (algo.toLowerCase()) {
'md5' => c.md5,
'sha256' => c.sha256,
_ => throw ArgumentError('Unsupported checksum algo: $algo'),
};
// Stream the file into the hash converter; no extra buffers.
final digest = await hash.bind(file.openRead()).first; // -> c.Digest
return digest.toString(); // lowercase hex
}
bool _constantTimeEquals(String a, String b) {
if (a.length != b.length) return false;
var result = 0;
for (var i = 0; i < a.length; i++) {
result |= a.codeUnitAt(i) ^ b.codeUnitAt(i);
}
return result == 0;
}
}
@@ -1,30 +0,0 @@
import 'package:injectable/injectable.dart';
import 'package:minio/minio.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/remote_object_meta.dart';
import 'package:moonwell_launcher/features/downloader/domain/repositories/storage_reader.dart';
/// MinIO storage reader implementation.
@LazySingleton(as: StorageReader)
class MinioStorageReader implements StorageReader {
final Minio _client;
MinioStorageReader(this._client);
@override
Future<Stream<List<int>>> readFromOffset({
required String bucket,
required String key,
required int startOffset,
}) {
return _client.getPartialObject(bucket, key, startOffset);
}
@override
Future<RemoteObjectMeta> stat({
required String bucket,
required String key,
}) async {
final s = await _client.statObject(bucket, key);
return RemoteObjectMeta(size: s.size ?? 0, eTag: s.etag ?? '');
}
}
@@ -1,7 +0,0 @@
/// Represents a request to download an object from a storage bucket.
class DownloadRequest {
/// Local file path to save the downloaded object
final String destinationPath;
const DownloadRequest({required this.destinationPath});
}
@@ -1,17 +0,0 @@
/// Represents the result of a download operation.
class DownloadResult {
/// Local file path where the downloaded object is stored.
final String path;
/// Number of bytes downloaded.
final int bytes;
/// Entity tag (ETag) of the downloaded object.
final String eTag;
const DownloadResult({
required this.path,
required this.bytes,
required this.eTag,
});
}
@@ -1,10 +0,0 @@
/// Represents metadata for a remote object in a storage bucket.
class RemoteObjectMeta {
/// Size of the object in bytes.
final int size;
/// Entity tag (ETag) of the object.
final String eTag;
const RemoteObjectMeta({required this.size, required this.eTag});
}
@@ -1,29 +0,0 @@
import 'dart:async';
/// Interface for interacting with the file system.
abstract interface class FileSystem {
/// Ensures that the parent directory of the given path exists.
Future<void> ensureParentExists(String path);
/// Checks if a file or directory exists at the given path.
Future<bool> exists(String path);
/// Returns the size of the file at the given path.
Future<int> sizeOf(String path);
/// Truncates the file at the given path to zero length.
Future<void> truncate(String path);
/// Creates an empty file at the given path.
Future<void> createEmpty(String path);
/// Opens a stream sink for appending data to the file at the given path.
Future<StreamSink<List<int>>> openAppend(String path);
/// Verifies the integrity of a file at the given path.
Future<bool> verifyFile(
String path, {
required String expected,
required String algo,
});
}
@@ -1,14 +0,0 @@
import 'package:moonwell_launcher/features/downloader/domain/entities/remote_object_meta.dart';
/// Interface for reading data from storage.
abstract interface class StorageReader {
/// Retrieves metadata about a remote object.
Future<RemoteObjectMeta> stat({required String bucket, required String key});
/// Reads data from a remote object starting at the specified offset.
Future<Stream<List<int>>> readFromOffset({
required String bucket,
required String key,
required int startOffset,
});
}
@@ -1,160 +0,0 @@
import 'dart:async';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/config.dart';
import 'package:moonwell_launcher/core/use_case.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_request.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_result.dart';
import 'package:moonwell_launcher/features/downloader/domain/repositories/file_system.dart';
import 'package:moonwell_launcher/features/downloader/domain/repositories/storage_reader.dart';
final class DownloadObjectUseCaseInput {
final DownloadRequest request;
final FutureOr<bool> Function()? isCancelled;
final void Function(DownloadResult result)? onComplete;
const DownloadObjectUseCaseInput({
required this.request,
this.isCancelled,
this.onComplete,
});
}
@lazySingleton
class DownloadObjectUseCase
implements StreamUseCase<DownloadObjectUseCaseInput, DownloadProgress> {
final StorageReader _storage;
final FileSystem _fileSystem;
const DownloadObjectUseCase({
required StorageReader storage,
required FileSystem fileSystem,
}) : _storage = storage,
_fileSystem = fileSystem;
@override
Stream<DownloadProgress> call(DownloadObjectUseCaseInput input) async* {
final request = input.request;
final meta = await _storage.stat(
bucket: Config.bucketName,
key: Config.objectName,
);
final total = meta.size;
final pathToFile = '${request.destinationPath}${Config.objectName}';
await _fileSystem.ensureParentExists(pathToFile);
int offset = 0;
if (await _fileSystem.exists(pathToFile)) {
offset = await _fileSystem.sizeOf(pathToFile);
if (offset > total) {
await _fileSystem.truncate(pathToFile);
offset = 0;
}
if (offset == total) {
input.onComplete?.call(
DownloadResult(path: pathToFile, bytes: offset, eTag: meta.eTag),
);
return;
}
} else {
await _fileSystem.createEmpty(pathToFile);
}
final objectStream = await _storage.readFromOffset(
bucket: Config.bucketName,
key: Config.objectName,
startOffset: offset,
);
final sink = await _fileSystem.openAppend(pathToFile);
final controller = StreamController<DownloadProgress>();
final sw = Stopwatch()..start();
int lastBytes = 0;
int downloaded = offset;
Timer? ticker;
void tick() {
final elapsedMs = sw.elapsedMilliseconds;
final bps = elapsedMs == 0 ? 0 : (lastBytes * 1000) ~/ elapsedMs;
final remaining = (total - downloaded).clamp(0, total);
final eta = bps > 0 ? Duration(seconds: remaining ~/ bps) : Duration.zero;
controller.add(
DownloadProgress(
downloaded: downloaded,
total: total,
speed: bps,
eta: eta,
),
);
lastBytes = 0;
sw
..reset()
..start();
}
ticker = Timer.periodic(const Duration(seconds: 1), (_) => tick());
late StreamSubscription<List<int>> sub;
controller.onCancel = () async {
await sub.cancel();
await sink.close();
ticker?.cancel();
};
sub = objectStream.listen(
(chunk) async {
if (input.isCancelled != null && await input.isCancelled!.call()) {
ticker?.cancel();
await sub.cancel();
await sink.close();
controller.addError(const CancelledException());
await controller.close();
return;
}
sink.add(chunk);
final n = chunk.length;
downloaded += n;
lastBytes += n;
},
onError: (e, st) async {
ticker?.cancel();
await sink.close();
await controller.close();
throw _mapError(e);
},
onDone: () async {
ticker?.cancel();
tick(); // final snapshot
await sink.close();
await controller.close();
input.onComplete?.call(
DownloadResult(path: pathToFile, bytes: downloaded, eTag: meta.eTag),
);
},
cancelOnError: true,
);
yield* controller.stream;
}
DownloadException _mapError(Object e) {
final s = e.toString();
if (s.contains('SocketException') || s.contains('HandshakeException')) {
return NetworkException(s);
}
if (s.contains('Minio')) return RemoteException(s);
return IoException(s);
}
}
@@ -0,0 +1,453 @@
import 'dart:async';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/core/use_case.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
final class ClientSyncRequest {
final String installationDir;
final String accessToken;
final ClientManifest? manifest;
const ClientSyncRequest({
required this.installationDir,
required this.accessToken,
this.manifest,
});
}
final class ClientSyncUseCaseInput {
final ClientSyncRequest request;
final FutureOr<bool> Function()? isCancelled;
const ClientSyncUseCaseInput({required this.request, this.isCancelled});
}
@lazySingleton
class ClientSyncUseCase
implements StreamUseCase<ClientSyncUseCaseInput, ClientSyncStatus> {
ClientSyncUseCase({
required LauncherApiClient launcherApiClient,
required GameInstallationService installationService,
}) : _launcherApiClient = launcherApiClient,
_installationService = installationService;
final LauncherApiClient _launcherApiClient;
final GameInstallationService _installationService;
@override
Stream<ClientSyncStatus> call(ClientSyncUseCaseInput input) {
final controller = StreamController<ClientSyncStatus>();
unawaited(_run(input, controller));
return controller.stream;
}
Future<void> _run(
ClientSyncUseCaseInput input,
StreamController<ClientSyncStatus> controller,
) async {
try {
await _throwIfCancelled(input.isCancelled);
_emit(
controller,
ClientSyncStatus(
stage: ClientSyncStage.fetchingManifest,
progress: const DownloadProgress.initial(),
message: 'Loading client manifest...',
currentPath: null,
localBuildHash: null,
remoteBuildHash: null,
processedFiles: 0,
totalFiles: 0,
),
);
final manifest =
input.request.manifest ??
await _launcherApiClient.fetchManifest(input.request.accessToken);
final remoteBuildHash = manifest.buildHash;
final localSnapshot = await _installationService.scanInstallation(
input.request.installationDir,
isCancelled: input.isCancelled,
onProgress: (progress, currentPath, processedFiles, totalFiles) {
_emit(
controller,
ClientSyncStatus(
stage: ClientSyncStage.scanningLocalFiles,
progress: progress,
message: 'Hashing local files...',
currentPath: currentPath,
localBuildHash: null,
remoteBuildHash: remoteBuildHash,
processedFiles: processedFiles,
totalFiles: totalFiles,
),
);
},
);
await _throwIfCancelled(input.isCancelled);
final filesToDownload = _resolveFilesToDownload(manifest, localSnapshot);
final staleFiles = _resolveStaleFiles(manifest, localSnapshot);
_emit(
controller,
ClientSyncStatus(
stage: ClientSyncStage.comparingFiles,
progress: DownloadProgress(
speed: 0,
downloaded: localSnapshot.files.length,
total: manifest.files.length,
eta: Duration.zero,
),
message: _buildComparisonMessage(
localSnapshot: localSnapshot,
remoteBuildHash: remoteBuildHash,
filesToDownload: filesToDownload.length,
staleFiles: staleFiles.length,
),
currentPath: null,
localBuildHash: localSnapshot.buildHash,
remoteBuildHash: remoteBuildHash,
processedFiles: localSnapshot.files.length,
totalFiles: manifest.files.length,
),
);
if (localSnapshot.buildHash == remoteBuildHash &&
filesToDownload.isEmpty &&
staleFiles.isEmpty) {
_emit(
controller,
ClientSyncStatus(
stage: ClientSyncStage.completed,
progress: _fullProgress(manifest),
message: 'Client is up to date.',
currentPath: null,
localBuildHash: localSnapshot.buildHash,
remoteBuildHash: remoteBuildHash,
processedFiles: manifest.files.length,
totalFiles: manifest.files.length,
),
);
return;
}
if (staleFiles.isNotEmpty) {
await _removeStaleFiles(
controller: controller,
installationDir: input.request.installationDir,
staleFiles: staleFiles,
remoteBuildHash: remoteBuildHash,
isCancelled: input.isCancelled,
);
}
if (filesToDownload.isNotEmpty) {
await _downloadChangedFiles(
controller: controller,
manifest: manifest,
accessToken: input.request.accessToken,
installationDir: input.request.installationDir,
filesToDownload: filesToDownload,
remoteBuildHash: remoteBuildHash,
isCancelled: input.isCancelled,
);
}
final verifiedSnapshot = await _installationService.scanInstallation(
input.request.installationDir,
isCancelled: input.isCancelled,
onProgress: (progress, currentPath, processedFiles, totalFiles) {
_emit(
controller,
ClientSyncStatus(
stage: ClientSyncStage.verifyingInstallation,
progress: progress,
message: 'Verifying updated client...',
currentPath: currentPath,
localBuildHash: null,
remoteBuildHash: remoteBuildHash,
processedFiles: processedFiles,
totalFiles: totalFiles,
),
);
},
);
await _throwIfCancelled(input.isCancelled);
_ensureVerificationPassed(
manifest: manifest,
snapshot: verifiedSnapshot,
remoteBuildHash: remoteBuildHash,
);
_emit(
controller,
ClientSyncStatus(
stage: ClientSyncStage.completed,
progress: _fullProgress(manifest),
message: 'Client is ready to play.',
currentPath: null,
localBuildHash: verifiedSnapshot.buildHash,
remoteBuildHash: remoteBuildHash,
processedFiles: manifest.files.length,
totalFiles: manifest.files.length,
),
);
} catch (error, stackTrace) {
if (!controller.isClosed) {
controller.addError(error, stackTrace);
}
} finally {
await controller.close();
}
}
List<ClientManifestFile> _resolveFilesToDownload(
ClientManifest manifest,
ClientInstallationSnapshot snapshot,
) {
final localFilesByPath = snapshot.filesByPath;
return manifest.files.where((remoteFile) {
final localFile = localFilesByPath[remoteFile.path];
return localFile == null ||
localFile.sha256 != remoteFile.sha256 ||
localFile.size != remoteFile.size;
}).toList();
}
List<String> _resolveStaleFiles(
ClientManifest manifest,
ClientInstallationSnapshot snapshot,
) {
final remoteFiles = manifest.filesByPath;
return snapshot.files
.where((localFile) => !remoteFiles.containsKey(localFile.path))
.map((file) => file.path)
.toList();
}
Future<void> _removeStaleFiles({
required StreamController<ClientSyncStatus> controller,
required String installationDir,
required List<String> staleFiles,
required String remoteBuildHash,
required FutureOr<bool> Function()? isCancelled,
}) async {
var removedFiles = 0;
for (final relativePath in staleFiles) {
await _throwIfCancelled(isCancelled);
await _installationService.deleteRelativeFile(
installationDir,
relativePath,
);
removedFiles += 1;
_emit(
controller,
ClientSyncStatus(
stage: ClientSyncStage.removingStaleFiles,
progress: DownloadProgress(
speed: 0,
downloaded: removedFiles,
total: staleFiles.length,
eta: Duration.zero,
),
message: 'Removing obsolete files...',
currentPath: relativePath,
localBuildHash: null,
remoteBuildHash: remoteBuildHash,
processedFiles: removedFiles,
totalFiles: staleFiles.length,
),
);
}
}
Future<void> _downloadChangedFiles({
required StreamController<ClientSyncStatus> controller,
required ClientManifest manifest,
required String accessToken,
required String installationDir,
required List<ClientManifestFile> filesToDownload,
required String remoteBuildHash,
required FutureOr<bool> Function()? isCancelled,
}) async {
final totalBytes = filesToDownload.fold<int>(
0,
(sum, file) => sum + file.size,
);
final stopwatch = Stopwatch()..start();
var downloadedBytes = 0;
var bytesSinceLastTick = 0;
var processedFiles = 0;
String? currentPath;
void emitProgress() {
final elapsedMs = stopwatch.elapsedMilliseconds;
final speed = elapsedMs == 0
? 0
: (bytesSinceLastTick * 1000) ~/ elapsedMs;
final remainingBytes = totalBytes - downloadedBytes;
final eta = speed > 0
? Duration(seconds: remainingBytes ~/ speed)
: Duration.zero;
_emit(
controller,
ClientSyncStatus(
stage: ClientSyncStage.downloadingFiles,
progress: DownloadProgress(
speed: speed,
downloaded: downloadedBytes,
total: totalBytes,
eta: eta,
),
message: 'Downloading changed files...',
currentPath: currentPath,
localBuildHash: null,
remoteBuildHash: remoteBuildHash,
processedFiles: processedFiles,
totalFiles: filesToDownload.length,
),
);
bytesSinceLastTick = 0;
stopwatch
..reset()
..start();
}
final ticker = Timer.periodic(
const Duration(seconds: 1),
(_) => emitProgress(),
);
try {
for (final file in filesToDownload) {
await _throwIfCancelled(isCancelled);
currentPath = file.path;
final destinationPath = _installationService.resolveClientPath(
installationDir,
file.path,
);
final temporaryPath = '$destinationPath.moonwell.part';
await _installationService.deleteFileIfExists(temporaryPath);
await _launcherApiClient.downloadFile(
accessToken: accessToken,
file: file,
destinationPath: temporaryPath,
isCancelled: isCancelled,
onChunkReceived: (chunkBytes) {
downloadedBytes += chunkBytes;
bytesSinceLastTick += chunkBytes;
},
);
final isValid = await _installationService.verifySha256(
temporaryPath,
file.sha256,
);
if (!isValid) {
await _installationService.deleteFileIfExists(temporaryPath);
throw LauncherSyncException(
'Downloaded file failed verification: ${file.path}',
);
}
await _installationService.replaceFile(
temporaryPath: temporaryPath,
destinationPath: destinationPath,
);
processedFiles += 1;
emitProgress();
}
} finally {
ticker.cancel();
}
}
void _ensureVerificationPassed({
required ClientManifest manifest,
required ClientInstallationSnapshot snapshot,
required String remoteBuildHash,
}) {
final localFilesByPath = snapshot.filesByPath;
final mismatchedFiles = manifest.files.where((remoteFile) {
final localFile = localFilesByPath[remoteFile.path];
return localFile == null ||
localFile.sha256 != remoteFile.sha256 ||
localFile.size != remoteFile.size;
}).toList();
final extraFiles = snapshot.files
.where((localFile) => !manifest.filesByPath.containsKey(localFile.path))
.toList();
if (snapshot.buildHash != remoteBuildHash ||
mismatchedFiles.isNotEmpty ||
extraFiles.isNotEmpty) {
throw LauncherSyncException('Client verification failed after update.');
}
}
String _buildComparisonMessage({
required ClientInstallationSnapshot localSnapshot,
required String remoteBuildHash,
required int filesToDownload,
required int staleFiles,
}) {
if (localSnapshot.buildHash == remoteBuildHash &&
filesToDownload == 0 &&
staleFiles == 0) {
return 'Local build matches the server manifest.';
}
return 'Update required: $filesToDownload file(s) to download, '
'$staleFiles file(s) to remove.';
}
DownloadProgress _fullProgress(ClientManifest manifest) {
return DownloadProgress(
speed: 0,
downloaded: manifest.totalSize,
total: manifest.totalSize,
eta: Duration.zero,
);
}
void _emit(
StreamController<ClientSyncStatus> controller,
ClientSyncStatus status,
) {
if (!controller.isClosed) {
controller.add(status);
}
}
Future<void> _throwIfCancelled(FutureOr<bool> Function()? isCancelled) async {
if (isCancelled != null && await isCancelled()) {
throw const CancelledException();
}
}
}
@@ -0,0 +1,256 @@
import 'dart:async';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/config.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
import 'package:path/path.dart' as p;
typedef InstallationScanProgressCallback =
void Function(
DownloadProgress progress,
String currentPath,
int processedFiles,
int totalFiles,
);
@lazySingleton
class GameInstallationService {
Future<ClientInstallationSnapshot> scanInstallation(
String installationDir, {
InstallationScanProgressCallback? onProgress,
FutureOr<bool> Function()? isCancelled,
}) async {
final rootDirectory = Directory(installationDir);
if (!await rootDirectory.exists()) {
return ClientInstallationSnapshot.fromFiles(const []);
}
final pendingFiles = <_PendingFile>[];
await _collectFiles(
rootDirectory,
rootDirectory.path,
pendingFiles,
isCancelled: isCancelled,
);
pendingFiles.sort(
(left, right) => left.relativePath.compareTo(right.relativePath),
);
final totalBytes = pendingFiles.fold<int>(
0,
(sum, pendingFile) => sum + pendingFile.size,
);
final files = <LocalClientFile>[];
var processedBytes = 0;
var processedFiles = 0;
for (final pendingFile in pendingFiles) {
await _throwIfCancelled(isCancelled);
final sha256 = await computeSha256(pendingFile.file.path);
processedBytes += pendingFile.size;
processedFiles += 1;
files.add(
LocalClientFile(
path: pendingFile.relativePath,
size: pendingFile.size,
sha256: sha256,
),
);
onProgress?.call(
DownloadProgress(
speed: 0,
downloaded: processedBytes,
total: totalBytes,
eta: Duration.zero,
),
pendingFile.relativePath,
processedFiles,
pendingFiles.length,
);
}
return ClientInstallationSnapshot.fromFiles(files);
}
Future<String> computeSha256(String filePath) async {
final digest = await sha256.bind(File(filePath).openRead()).first;
return digest.toString();
}
Future<bool> verifySha256(String filePath, String expectedHash) async {
final file = File(filePath);
if (!await file.exists()) {
return false;
}
final actualHash = await computeSha256(filePath);
return actualHash.toLowerCase() == expectedHash.toLowerCase();
}
Future<void> deleteRelativeFile(
String installationDir,
String relativePath,
) async {
final file = File(resolveClientPath(installationDir, relativePath));
if (await file.exists()) {
await file.delete();
}
}
Future<void> replaceFile({
required String temporaryPath,
required String destinationPath,
}) async {
final destinationFile = File(destinationPath);
await destinationFile.parent.create(recursive: true);
if (await destinationFile.exists()) {
await destinationFile.delete();
}
await File(temporaryPath).rename(destinationPath);
}
Future<void> deleteFileIfExists(String filePath) async {
final file = File(filePath);
if (await file.exists()) {
await file.delete();
}
}
Future<void> clearCache(String installationDir) async {
final cacheDirectory = Directory(
p.join(installationDir, Config.cacheDirectoryName),
);
if (await cacheDirectory.exists()) {
await cacheDirectory.delete(recursive: true);
}
await cacheDirectory.create(recursive: true);
}
Future<void> launchGame(String installationDir) async {
final executablePath = getExecutablePath(installationDir);
final executable = File(executablePath);
if (!await executable.exists()) {
throw const LauncherSyncException(
'Wow.exe was not found in the selected folder.',
);
}
await Process.start(
executablePath,
const <String>[],
workingDirectory: installationDir,
mode: ProcessStartMode.detached,
);
}
Future<bool> hasClientExecutable(String installationDir) async {
return File(getExecutablePath(installationDir)).exists();
}
String getExecutablePath(String installationDir) {
return p.join(installationDir, Config.gameExecutableName);
}
String resolveClientPath(String installationDir, String relativePath) {
final normalizedPath = normalizeClientPath(relativePath);
final pathSegments = p.posix
.split(normalizedPath)
.where((segment) => segment.isNotEmpty && segment != '.')
.toList();
if (pathSegments.isEmpty ||
pathSegments.any((segment) => segment == '..') ||
p.posix.isAbsolute(normalizedPath)) {
throw LauncherSyncException('Invalid client file path: $relativePath');
}
return p.joinAll([installationDir, ...pathSegments]);
}
Future<void> _collectFiles(
Directory directory,
String rootPath,
List<_PendingFile> pendingFiles, {
FutureOr<bool> Function()? isCancelled,
}) async {
await for (final entity in directory.list(followLinks: false)) {
await _throwIfCancelled(isCancelled);
final relativePath = normalizeClientPath(
p.relative(entity.path, from: rootPath),
);
if (relativePath.isEmpty || relativePath == '.') {
continue;
}
if (_isExcludedPath(relativePath)) {
continue;
}
if (entity is Directory) {
await _collectFiles(
entity,
rootPath,
pendingFiles,
isCancelled: isCancelled,
);
continue;
}
if (entity is! File) {
continue;
}
final stat = await entity.stat();
pendingFiles.add(
_PendingFile(file: entity, relativePath: relativePath, size: stat.size),
);
}
}
bool _isExcludedPath(String relativePath) {
final segments = p.posix.split(normalizeClientPath(relativePath));
if (segments.isEmpty) {
return false;
}
return Config.ignoredVerificationDirectories.contains(
segments.first.toLowerCase(),
);
}
Future<void> _throwIfCancelled(FutureOr<bool> Function()? isCancelled) async {
if (isCancelled != null && await isCancelled()) {
throw const CancelledException();
}
}
}
final class _PendingFile {
final File file;
final String relativePath;
final int size;
const _PendingFile({
required this.file,
required this.relativePath,
required this.size,
});
}
@@ -0,0 +1,176 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/config.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
@lazySingleton
class LauncherApiClient {
LauncherApiClient()
: _dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(minutes: 30),
headers: const {HttpHeaders.acceptHeader: 'application/json'},
),
);
final Dio _dio;
Future<LauncherSession> login({
required String username,
required String password,
}) async {
try {
final response = await _dio.postUri(
_resolveUri('api/launcher/login'),
data: {'username': username, 'password': password},
);
return LauncherSession.fromJson(_coerceMap(response.data));
} on DioException catch (error) {
throw LauncherApiException(_extractErrorMessage(error));
}
}
Future<ClientManifest> fetchManifest(String accessToken) async {
try {
final response = await _dio.getUri(
_resolveUri('api/launcher/manifest'),
options: _authorizedOptions(accessToken),
);
return ClientManifest.fromJson(_coerceMap(response.data));
} on DioException catch (error) {
throw LauncherApiException(_extractErrorMessage(error));
}
}
Future<void> downloadFile({
required String accessToken,
required ClientManifestFile file,
required String destinationPath,
required FutureOr<bool> Function()? isCancelled,
required void Function(int chunkBytes) onChunkReceived,
}) async {
try {
final response = await _dio.getUri<ResponseBody>(
_resolveUri('api/launcher/download/${Uri.encodeComponent(file.path)}'),
options: _authorizedOptions(
accessToken,
responseType: ResponseType.stream,
headers: const {HttpHeaders.acceptHeader: 'application/octet-stream'},
),
);
final stream = response.data?.stream;
if (stream == null) {
throw const LauncherApiException(
'Server returned an empty file stream.',
);
}
final sink = File(destinationPath).openWrite();
try {
await for (final chunk in stream) {
if (isCancelled != null && await isCancelled()) {
throw const CancelledException();
}
sink.add(chunk);
onChunkReceived(chunk.length);
}
} finally {
await sink.close();
}
} on DioException catch (error) {
throw LauncherApiException(_extractErrorMessage(error));
}
}
Options _authorizedOptions(
String accessToken, {
ResponseType? responseType,
Map<String, Object?>? headers,
}) {
return Options(
responseType: responseType,
headers: {
HttpHeaders.authorizationHeader: 'Bearer $accessToken',
...?headers,
},
);
}
Uri _resolveUri(String path) {
final rawBaseUrl = Config.launcherApiBaseUrl.trim();
if (rawBaseUrl.isEmpty) {
throw const LauncherConfigurationException(
'Set MOONWELL_API_BASE_URL before using the launcher API.',
);
}
final baseUri = Uri.parse(
rawBaseUrl.endsWith('/') ? rawBaseUrl : '$rawBaseUrl/',
);
if (!baseUri.hasScheme || baseUri.host.isEmpty) {
throw const LauncherConfigurationException(
'MOONWELL_API_BASE_URL must be an absolute URL.',
);
}
return baseUri.resolve(path);
}
Map<String, dynamic> _coerceMap(Object? data) {
if (data is Map<String, dynamic>) {
return data;
}
if (data is Map) {
return data.map((key, value) => MapEntry(key.toString(), value));
}
throw const LauncherApiException('Unexpected API response payload.');
}
String _extractErrorMessage(DioException error) {
final data = error.response?.data;
if (data is Map<String, dynamic>) {
final message = data['message'];
if (message is String && message.trim().isNotEmpty) {
return message;
}
}
if (data is Map) {
final message = data['message'];
if (message is String && message.trim().isNotEmpty) {
return message;
}
}
if (data is String && data.trim().isNotEmpty) {
return data;
}
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.error is SocketException) {
return 'Unable to reach the launcher API.';
}
return error.message ?? 'Launcher API request failed.';
}
}
@@ -0,0 +1,30 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:path/path.dart' as p;
abstract interface class ClientHashEntry {
String get path;
int get size;
String get sha256;
}
String normalizeClientPath(String rawPath) {
final sanitized = rawPath.replaceAll('\\', '/').trim();
final normalized = p.posix.normalize(sanitized);
return normalized.startsWith('./') ? normalized.substring(2) : normalized;
}
String computeBuildHash(Iterable<ClientHashEntry> entries) {
final canonicalEntries =
entries
.map(
(entry) =>
'${normalizeClientPath(entry.path)}:${entry.size}:${entry.sha256.toLowerCase()}',
)
.toList()
..sort();
final payload = canonicalEntries.join('\n');
return sha256.convert(utf8.encode(payload)).toString();
}
@@ -0,0 +1,23 @@
import 'client_hash_entry.dart';
import 'local_client_file.dart';
final class ClientInstallationSnapshot {
final List<LocalClientFile> files;
final String buildHash;
const ClientInstallationSnapshot({
required this.files,
required this.buildHash,
});
factory ClientInstallationSnapshot.fromFiles(List<LocalClientFile> files) {
return ClientInstallationSnapshot(
files: files,
buildHash: computeBuildHash(files),
);
}
Map<String, LocalClientFile> get filesByPath => {
for (final file in files) normalizeClientPath(file.path): file,
};
}
@@ -0,0 +1,26 @@
import 'client_hash_entry.dart';
import 'client_manifest_file.dart';
final class ClientManifest {
final List<ClientManifestFile> files;
final String buildHash;
const ClientManifest({required this.files, required this.buildHash});
factory ClientManifest.fromJson(Map<String, dynamic> json) {
final files =
((json['files'] as List<dynamic>? ?? const <dynamic>[])
.whereType<Map<String, dynamic>>()
.map(ClientManifestFile.fromJson)
.toList())
.cast<ClientManifestFile>();
return ClientManifest(files: files, buildHash: computeBuildHash(files));
}
Map<String, ClientManifestFile> get filesByPath => {
for (final file in files) normalizeClientPath(file.path): file,
};
int get totalSize => files.fold(0, (sum, file) => sum + file.size);
}
@@ -0,0 +1,26 @@
import 'client_hash_entry.dart';
final class ClientManifestFile implements ClientHashEntry {
@override
final String path;
@override
final int size;
@override
final String sha256;
const ClientManifestFile({
required this.path,
required this.size,
required this.sha256,
});
factory ClientManifestFile.fromJson(Map<String, dynamic> json) {
return ClientManifestFile(
path: normalizeClientPath(json['path'] as String? ?? ''),
size: (json['size'] as num? ?? 0).toInt(),
sha256: (json['sha256'] as String? ?? '').toLowerCase(),
);
}
}
@@ -0,0 +1,33 @@
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
enum ClientSyncStage {
fetchingManifest,
scanningLocalFiles,
comparingFiles,
removingStaleFiles,
downloadingFiles,
verifyingInstallation,
completed,
}
final class ClientSyncStatus {
final ClientSyncStage stage;
final DownloadProgress progress;
final String message;
final String? currentPath;
final String? localBuildHash;
final String? remoteBuildHash;
final int processedFiles;
final int totalFiles;
const ClientSyncStatus({
required this.stage,
required this.progress,
required this.message,
required this.currentPath,
required this.localBuildHash,
required this.remoteBuildHash,
required this.processedFiles,
required this.totalFiles,
});
}
@@ -0,0 +1,20 @@
sealed class LauncherException implements Exception {
final String message;
const LauncherException(this.message);
@override
String toString() => message;
}
final class LauncherConfigurationException extends LauncherException {
const LauncherConfigurationException(super.message);
}
final class LauncherApiException extends LauncherException {
const LauncherApiException(super.message);
}
final class LauncherSyncException extends LauncherException {
const LauncherSyncException(super.message);
}
@@ -0,0 +1,27 @@
final class LauncherSession {
final String accessToken;
final String tokenType;
final DateTime? expiresAt;
const LauncherSession({
required this.accessToken,
required this.tokenType,
required this.expiresAt,
});
factory LauncherSession.fromJson(Map<String, dynamic> json) {
return LauncherSession(
accessToken: json['access_token'] as String? ?? '',
tokenType: json['token_type'] as String? ?? 'Bearer',
expiresAt: _parseDateTime(json['expires_at'] as String?),
);
}
static DateTime? _parseDateTime(String? rawValue) {
if (rawValue == null || rawValue.isEmpty) {
return null;
}
return DateTime.tryParse(rawValue);
}
}
@@ -0,0 +1,18 @@
import 'client_hash_entry.dart';
final class LocalClientFile implements ClientHashEntry {
@override
final String path;
@override
final int size;
@override
final String sha256;
const LocalClientFile({
required this.path,
required this.size,
required this.sha256,
});
}
+8 -5
View File
@@ -214,7 +214,7 @@ class _LeftPane extends StatelessWidget {
Text( Text(
'Classic MMO Launcher', 'Classic MMO Launcher',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: cs.onSurface.withOpacity(0.85), color: cs.onSurface.withValues(alpha: 0.85),
), ),
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
@@ -289,7 +289,7 @@ class _LeftPane extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surfaceVariant.withOpacity(0.5), color: cs.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline), border: Border.all(color: cs.outline),
boxShadow: (deco.cardGlow), boxShadow: (deco.cardGlow),
@@ -332,7 +332,7 @@ class _GildedProgressBar extends StatelessWidget {
return Container( return Container(
height: 20, height: 20,
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surfaceVariant, color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
border: Border.all(color: cs.outline), border: Border.all(color: cs.outline),
), ),
@@ -352,7 +352,10 @@ class _GildedProgressBar extends StatelessWidget {
// Subtle top highlight // Subtle top highlight
Align( Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: Container(height: 6, color: Colors.white.withOpacity(0.08)), child: Container(
height: 6,
color: Colors.white.withValues(alpha: 0.08),
),
), ),
], ],
), ),
@@ -446,7 +449,7 @@ class _PatchNotesPane extends StatelessWidget {
child: Text( child: Text(
s, s,
style: TextStyle( style: TextStyle(
color: cs.onSurface.withOpacity(0.9), color: cs.onSurface.withValues(alpha: 0.9),
), ),
), ),
), ),
-86
View File
@@ -1,86 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:logging/logging.dart';
import 'package:moonwell_launcher/features/downloader/application/download_manager.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_request.dart';
import 'package:moonwell_launcher/service_container.dart';
final _log = Logger('Downloader');
Future<void> main(List<String> arguments) async {
await configureDependencies();
_setupLogging();
final manager = getIt<DownloadManager>();
// Start the download and keep a handle to the subscription
final stream = manager.start(
id: 'client',
request: DownloadRequest(
destinationPath: '/Users/kalistratovm/Games/MoonWell/client.zip',
),
);
final done = Completer<int>(); // exit code completer
late StreamSubscription sub;
// Graceful shutdown on Ctrl-C / SIGTERM
final sigintSub = ProcessSignal.sigint.watch();
sigintSub.listen((_) async {
_log.warning('SIGINT received. Cancelling all downloads…');
await manager.cancelAll();
await sub.cancel();
if (!done.isCompleted) {
done.complete(130); // 130 = terminated by Ctrl-C
}
});
final sigtermSub = ProcessSignal.sigterm.watch().listen((_) async {
_log.warning('SIGTERM received. Cancelling all downloads…');
await manager.cancelAll();
await sub.cancel();
done.complete(143); // 143 = terminated by SIGTERM
});
sub = stream.listen(
(p) {
_log.info('Speed: ${p.speed}');
_log.info('ETA: ${p.eta}');
_log.info('Done: ${p.downloaded}/${p.total}');
},
onError: (e, st) async {
_log.severe('Download error', e, st);
await sigtermSub.cancel();
done.complete(1);
},
onDone: () async {
_log.info('Download completed');
await sigtermSub.cancel();
done.complete(0);
},
cancelOnError: true,
);
// ⬇️ Keep process alive until one of the completions above fires
final code = await done.future;
exit(code);
}
void _setupLogging() {
Logger.root.level = Level.INFO;
Logger.root.onRecord.listen((rec) {
final color = switch (rec.level) {
Level.SEVERE || Level.SHOUT => '\x1B[31m',
Level.WARNING => '\x1B[33m',
Level.INFO => '\x1B[36m',
_ => '\x1B[90m',
};
stdout.writeln(
'$color[${rec.time.toIso8601String()}] '
'${rec.level.name.padRight(7)} '
'${rec.loggerName}: ${rec.message}\x1B[0m',
);
if (rec.error != null) stdout.writeln(' Error: ${rec.error}');
if (rec.stackTrace != null) stdout.writeln(' Stack: ${rec.stackTrace}');
});
}
+12 -24
View File
@@ -11,20 +11,15 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes // ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:get_it/get_it.dart' as _i174; import 'package:get_it/get_it.dart' as _i174;
import 'package:injectable/injectable.dart' as _i526; import 'package:injectable/injectable.dart' as _i526;
import 'package:minio/minio.dart' as _i875;
import 'package:shared_preferences/shared_preferences.dart' as _i460; import 'package:shared_preferences/shared_preferences.dart' as _i460;
import 'features/downloader/application/download_manager.dart' as _i535; import 'features/launcher/application/client_sync_use_case.dart' as _i757;
import 'features/downloader/data/io_file_system.dart' as _i173; import 'features/launcher/data/game_installation_service.dart' as _i315;
import 'features/downloader/data/minio_storage_reader.dart' as _i980; import 'features/launcher/data/launcher_api_client.dart' as _i703;
import 'features/downloader/domain/repositories/file_system.dart' as _i843;
import 'features/downloader/domain/repositories/storage_reader.dart' as _i839;
import 'features/downloader/use_cases/download_object_use_case.dart' as _i925;
import 'features/preferences/data/repositories/shared_prefs_preferences_repository.dart' import 'features/preferences/data/repositories/shared_prefs_preferences_repository.dart'
as _i662; as _i662;
import 'features/preferences/domain/repositories/preferences_repository.dart' import 'features/preferences/domain/repositories/preferences_repository.dart'
as _i44; as _i44;
import 'third_party/minio.dart' as _i285;
import 'third_party/shared_preferences.dart' as _i1006; import 'third_party/shared_preferences.dart' as _i1006;
const String _flutter = 'flutter'; const String _flutter = 'flutter';
@@ -37,35 +32,28 @@ extension GetItInjectableX on _i174.GetIt {
}) async { }) async {
final gh = _i526.GetItHelper(this, environment, environmentFilter); final gh = _i526.GetItHelper(this, environment, environmentFilter);
final sharedPreferencesModule = _$SharedPreferencesModule(); final sharedPreferencesModule = _$SharedPreferencesModule();
final minioModule = _$MinioModule();
await gh.factoryAsync<_i460.SharedPreferences>( await gh.factoryAsync<_i460.SharedPreferences>(
() => sharedPreferencesModule.prefs, () => sharedPreferencesModule.prefs,
preResolve: true, preResolve: true,
); );
gh.lazySingleton<_i875.Minio>(() => minioModule.minioClient); gh.lazySingleton<_i315.GameInstallationService>(
gh.lazySingleton<_i839.StorageReader>( () => _i315.GameInstallationService(),
() => _i980.MinioStorageReader(gh<_i875.Minio>()), );
gh.lazySingleton<_i703.LauncherApiClient>(() => _i703.LauncherApiClient());
gh.lazySingleton<_i757.ClientSyncUseCase>(
() => _i757.ClientSyncUseCase(
launcherApiClient: gh<_i703.LauncherApiClient>(),
installationService: gh<_i315.GameInstallationService>(),
),
); );
gh.lazySingleton<_i843.FileSystem>(() => _i173.IoFileSystem());
gh.lazySingleton<_i44.PreferencesRepository>( gh.lazySingleton<_i44.PreferencesRepository>(
() => _i662.SharedPrefsPreferencesRepository( () => _i662.SharedPrefsPreferencesRepository(
preferences: gh<_i460.SharedPreferences>(), preferences: gh<_i460.SharedPreferences>(),
), ),
registerFor: {_flutter}, registerFor: {_flutter},
); );
gh.lazySingleton<_i925.DownloadObjectUseCase>(
() => _i925.DownloadObjectUseCase(
storage: gh<_i839.StorageReader>(),
fileSystem: gh<_i843.FileSystem>(),
),
);
gh.lazySingleton<_i535.DownloadManager>(
() => _i535.DownloadManager(gh<_i925.DownloadObjectUseCase>()),
);
return this; return this;
} }
} }
class _$SharedPreferencesModule extends _i1006.SharedPreferencesModule {} class _$SharedPreferencesModule extends _i1006.SharedPreferencesModule {}
class _$MinioModule extends _i285.MinioModule {}
-13
View File
@@ -1,13 +0,0 @@
import 'package:injectable/injectable.dart';
import 'package:minio/minio.dart';
import 'package:moonwell_launcher/config.dart';
@module
abstract class MinioModule {
@lazySingleton
Minio get minioClient => Minio(
endPoint: Config.host,
accessKey: Config.keyId,
secretKey: Config.secret,
);
}
+7 -47
View File
@@ -18,7 +18,7 @@ packages:
source: hosted source: hosted
version: "7.7.1" version: "7.7.1"
args: args:
dependency: "direct main" dependency: transitive
description: description:
name: args name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
@@ -49,14 +49,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
buffer:
dependency: transitive
description:
name: buffer
sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1"
url: "https://pub.dev"
source: hosted
version: "1.2.3"
build: build:
dependency: transitive dependency: transitive
description: description:
@@ -242,7 +234,7 @@ packages:
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
file: file:
dependency: "direct main" dependency: transitive
description: description:
name: file name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
@@ -336,14 +328,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.3.2"
http:
dependency: transitive
description:
name: http
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
url: "https://pub.dev"
source: hosted
version: "1.5.0"
http_multi_server: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@@ -376,14 +360,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.8.1" version: "2.8.1"
intl:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
io: io:
dependency: transitive dependency: transitive
description: description:
@@ -433,7 +409,7 @@ packages:
source: hosted source: hosted
version: "5.1.1" version: "5.1.1"
logging: logging:
dependency: "direct main" dependency: transitive
description: description:
name: logging name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
@@ -460,10 +436,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.17.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -472,14 +448,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
minio:
dependency: "direct main"
description:
name: minio
sha256: ee2ce47766e46c7d164f960f2f5ed6a9a82844d877f6b82574f6876ec50c56d1
url: "https://pub.dev"
source: hosted
version: "3.5.8"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -600,14 +568,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.0" version: "4.1.0"
rxdart:
dependency: "direct main"
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
screen_retriever: screen_retriever:
dependency: transitive dependency: transitive
description: description:
@@ -785,10 +745,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.6" version: "0.7.7"
timing: timing:
dependency: transitive dependency: transitive
description: description:
+1 -5
View File
@@ -34,17 +34,12 @@ dependencies:
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
file: ^7.0.1
dio: ^5.9.0 dio: ^5.9.0
minio: ^3.5.8
args: ^2.7.0
logging: ^1.3.0
path: ^1.9.1 path: ^1.9.1
injectable: ^2.5.1 injectable: ^2.5.1
crypto: ^3.0.6 crypto: ^3.0.6
get_it: ^8.2.0 get_it: ^8.2.0
flutter_bloc: ^9.1.1 flutter_bloc: ^9.1.1
rxdart: ^0.28.0
file_picker: ^10.3.2 file_picker: ^10.3.2
pixelarticons: ^0.4.0 pixelarticons: ^0.4.0
shared_preferences: ^2.5.3 shared_preferences: ^2.5.3
@@ -76,6 +71,7 @@ flutter:
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
assets: assets:
- assets/background.png - assets/background.png
- assets/logo.png
# - images/a_dot_ham.jpeg # - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see