diff --git a/README.md b/README.md
index 132f07d..21bef64 100644
--- a/README.md
+++ b/README.md
@@ -7,8 +7,10 @@ without excessive grind and with stronger social play.
The launcher is responsible for:
- logging in to a player's MoonWell account
+- registering new MoonWell game accounts
- downloading and synchronizing client files
- displaying launcher news
+- displaying the live status of the first configured realm
- launching the game client
## Stack
@@ -30,20 +32,20 @@ and all Dart and Flutter commands are run through FVM.
fvm flutter pub get
```
-Verify the configured SDK with `fvm flutter --version`.
+Verify the configured SDK with `flutter --version`.
## Configuration
Set the launcher Web API base URL at compile time:
```powershell
---dart-define=MOONWELL_API_BASE_URL=https://host
+--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online
```
Set the public AppCast feed URL to enable Windows launcher self-updates:
```powershell
---dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml
+--dart-define=MOONWELL_APPCAST_URL=https://moon-well.online/appcast.xml
```
The updater is disabled when `MOONWELL_APPCAST_URL` is omitted. Production
@@ -52,7 +54,7 @@ feeds must use HTTPS.
Example run command:
```powershell
-fvm flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://host --dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml
+flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://moon-well.online --dart-define=MOONWELL_APPCAST_URL=https://moon-well.online/appcast.xml
```
## Development
@@ -61,8 +63,8 @@ Run the component catalog from its standalone workspace:
```powershell
cd widgetbook
-fvm dart run build_runner build
-fvm flutter run -d windows
+dart run build_runner build
+flutter run -d windows
```
See `docs/widgetbook.md` for the required use-case naming, knobs, callback,
@@ -71,13 +73,13 @@ asset, and coverage conventions.
Regenerate dependency injection after changing injectable services:
```powershell
-fvm dart run build_runner build --delete-conflicting-outputs
+dart run build_runner build --delete-conflicting-outputs
```
Run static analysis:
```powershell
-fvm flutter analyze
+flutter analyze
```
See `docs/linting.md` for the full format, analyze, test, and Lefthook
@@ -86,13 +88,26 @@ pre-commit workflow.
Build the Windows launcher:
```powershell
-fvm flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host --dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml
+flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://moon-well.online --dart-define=MOONWELL_APPCAST_URL=https://moon-well.online/appcast.xml
```
The Inno Setup installer script lives at `installer/moonwell_launcher.iss`.
See `docs/launcher_self_update.md` for the AppCast format, signing keys, and
manual release procedure.
+Run a safe local production-release rehearsal:
+
+```powershell
+.\tool\deploy_launcher.ps1 `
+ -DryRun `
+ -ExpectedVersion 1.0.2 `
+ -ReleaseNotes "Исправления и улучшения MoonWell Launcher."
+```
+
+Remove `-DryRun` only after reviewing the generated installer and AppCast.
+See `docs/production_deploy.md` for required environment variables, production
+defaults, safety checks, and failure recovery.
+
## Architecture
The UI lives in `lib/app`. Presentation-only design-system components live in
@@ -104,13 +119,16 @@ preferences live under `lib/features`.
The launcher sync flow is manifest-based:
1. authenticate against the MoonWell Web API
-2. fetch the client manifest
-3. scan the selected installation directory
-4. compare local files to the manifest
-5. remove stale files outside ignored directories
-6. download missing or changed files to temporary part files
-7. verify size and SHA-256 before replacing client files
-8. launch `Wow.exe` from the selected client directory
+2. fetch the client manifest and automatically synchronize a saved client
+ installation
+3. refresh the manifest every five minutes while the launcher is open and
+ automatically synchronize when the server build changes
+4. scan the selected installation directory
+5. compare local files to the manifest
+6. remove stale files outside ignored directories
+7. download missing or changed files to temporary part files
+8. verify size and SHA-256 before replacing client files
+9. launch `Wow.exe` from the selected client directory
See `docs/launcher_web_api_spec.md` for the API and sync contract.
@@ -123,4 +141,5 @@ See `docs/launcher_web_api_spec.md` for the API and sync contract.
upgrade feasibility notes
- `docs/launcher_web_api_spec.md`: current launcher Web API and sync behavior
- `docs/launcher_self_update.md`: Windows self-update and release operations
+- `docs/production_deploy.md`: automated production launcher deployment
- `docs/widgetbook.md`: Widgetbook use-case generation and catalog conventions
diff --git a/appcast.xml b/appcast.xml
new file mode 100644
index 0000000..3de0c1a
--- /dev/null
+++ b/appcast.xml
@@ -0,0 +1,22 @@
+
+
+
+ MoonWell Launcher
+ Обновления MoonWell Launcher
+ ru
+ -
+ MoonWell Launcher 1.0.1
+ Обновление интерфейса, синхронизации клиента и интеграции с сервером MoonWell.
+ Tue, 28 Jul 2026 20:33:00 +0400
+
+
+
+
diff --git a/assets/launcher_key_art.png b/assets/launcher_key_art.png
new file mode 100644
index 0000000..15e019e
Binary files /dev/null and b/assets/launcher_key_art.png differ
diff --git a/assets/login_key_art.png b/assets/login_key_art.png
new file mode 100644
index 0000000..d281036
Binary files /dev/null and b/assets/login_key_art.png differ
diff --git a/docs/launcher_self_update.md b/docs/launcher_self_update.md
index 3c938fb..d0735b6 100644
--- a/docs/launcher_self_update.md
+++ b/docs/launcher_self_update.md
@@ -14,9 +14,9 @@ launcher. Periodic WinSparkle checks are disabled.
Pass the public feed URL at build time:
```powershell
-fvm flutter build windows `
- --dart-define=MOONWELL_API_BASE_URL=https://host `
- --dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml
+flutter build windows `
+ --dart-define=MOONWELL_API_BASE_URL=https://moon-well.online `
+ --dart-define=MOONWELL_APPCAST_URL=https://moon-well.online/appcast.xml
```
The feed URL must be an absolute HTTPS URL. Omit
@@ -67,10 +67,9 @@ Use a public AppCast with absolute URLs:
ru
-
MoonWell Launcher 1.1.0
- https://host/launcher/updates/1.1.0/release-notes.html
Sun, 21 Jun 2026 12:00:00 +0400
/news/{id}` in the
+ system browser.
+
+Realm status sequence:
+
+1. After the authenticated home screen opens, the launcher requests
+ `GET /api/launcher/realms`.
+2. The first item in the top-level `data` array supplies the realm name,
+ online state, and game build shown in the sync panel.
+3. Realm information is refreshed every minute while the launcher remains
+ open.
+4. A failed refresh keeps the last known realm state and is retried on the next
+ interval.
+
+Account sequence:
+
+1. After the authenticated home screen opens, the launcher requests
+ `GET /api/launcher/account` with bearer authentication.
+2. Only `username` is stored in launcher UI state and displayed in the account
+ menu.
+3. The response `balance` field is intentionally ignored and never displayed.
+4. Account metadata failures do not block patching or launching the game.
## Installation Directory Rules
@@ -193,6 +233,10 @@ When the user presses `Play`:
1. launcher resolves `/Wow.exe`
2. launcher clears `/Cache`
3. launcher starts `Wow.exe` with working directory set to installation root
+ and retains a process handle
+4. while the process is alive, the launcher disables repeated game launches
+ and client synchronization
+5. when the process exits, the launcher returns to the ready-to-play state
If `Wow.exe` is missing, launch fails with an error.
@@ -202,6 +246,7 @@ The launcher surfaces errors for:
- invalid API configuration
- authentication failure
+- registration validation or server failure
- manifest load failure
- path traversal attempts
- checksum mismatch after download
diff --git a/docs/production_deploy.md b/docs/production_deploy.md
new file mode 100644
index 0000000..d67e0e0
--- /dev/null
+++ b/docs/production_deploy.md
@@ -0,0 +1,152 @@
+# Production Launcher Deployment
+
+`tool/deploy_launcher.ps1` automates a complete Windows launcher release:
+
+1. reads `X.Y.Z+N` from `pubspec.yaml`
+2. verifies that the private DSA key matches the public key embedded in the
+ launcher
+3. runs dependency resolution, formatting checks, static analysis, and tests
+4. builds the Windows release with the production API and AppCast URLs
+5. compiles the Inno Setup installer
+6. signs the installer and verifies the signature
+7. generates `appcast.xml` with the real version, byte length, URL, and
+ signature
+8. uploads the installer to Yandex Object Storage with public read access
+9. downloads the published installer and compares its SHA-256
+10. publishes AppCast through the service API and verifies the public feed
+
+The installer is always uploaded before AppCast. Clients therefore never see a
+release that has not passed the remote download and checksum verification.
+
+## Prerequisites
+
+The release machine needs:
+
+- latest stable Flutter and Dart available on `PATH`
+- Inno Setup 6, normally installed at
+ `C:\Program Files (x86)\Inno Setup 6\ISCC.exe`
+- OpenSSL, normally supplied by Git for Windows at
+ `C:\Program Files\Git\usr\bin\openssl.exe`
+- Python 3 with `boto3`
+- `.moonwell_signing\dsa_priv.pem`
+- the matching public key at `windows\runner\resources\dsa_pub.pem`
+
+Install the Python dependency if required:
+
+```powershell
+python -m pip install boto3
+```
+
+Back up `.moonwell_signing\dsa_priv.pem` in the project secret store. Never
+commit it, upload it to Object Storage, or send it through chat. Losing this key
+prevents released launchers from accepting future updates.
+
+## Required Environment
+
+The script reads secrets and storage configuration only from environment
+variables:
+
+| Variable | Required | Purpose |
+| --- | --- | --- |
+| `AWS_ACCESS_KEY_ID` | yes | Yandex Object Storage access key |
+| `AWS_SECRET_ACCESS_KEY` | yes | Yandex Object Storage secret |
+| `LAUNCHER_AUTH_KEY` | yes | `Auth` header for the AppCast service endpoint |
+| `AWS_DEFAULT_REGION` | recommended | Defaults to `ru-central1` |
+| `AWS_ENDPOINT` | optional | Defaults to `https://storage.yandexcloud.net` |
+| `AWS_BUCKET` | optional | Defaults to `warcraft-client` |
+| `AWS_USE_PATH_STYLE_ENDPOINT` | optional | Defaults to `true` |
+
+Load secrets from the team secret manager into the current PowerShell process.
+Do not put real values in a tracked `.env` file or in the script.
+
+## Prepare a Release
+
+Increase both parts of the version in `pubspec.yaml`:
+
+```yaml
+version: 1.0.2+3
+```
+
+`X.Y.Z` is the WinSparkle release version. `N` is the Flutter build number.
+Every production release must have an `X.Y.Z` value greater than the version
+currently published in AppCast.
+
+Run a dry run first:
+
+```powershell
+.\tool\deploy_launcher.ps1 `
+ -DryRun `
+ -ExpectedVersion 1.0.2 `
+ -ReleaseNotes "Исправления и улучшения MoonWell Launcher."
+```
+
+Dry run performs the local build, packaging, signing, signature verification,
+and AppCast generation. It writes the generated feed to
+`build\launcher_release\appcast.xml` and does not change S3 or the website.
+
+## Deploy to Production
+
+After reviewing the dry-run artifacts:
+
+```powershell
+.\tool\deploy_launcher.ps1 `
+ -ExpectedVersion 1.0.2 `
+ -ReleaseNotes "Исправления и улучшения MoonWell Launcher."
+```
+
+The production defaults are:
+
+- API: `https://moon-well.online`
+- AppCast: `https://moon-well.online/appcast.xml`
+- bucket: `warcraft-client`
+- object: `moonwell_launcher_setup.exe`
+- installer:
+ `https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe`
+
+On success, the command prints the deployed version, installer URL, SHA-256,
+and AppCast URL. The tracked root `appcast.xml` contains the exact feed sent to
+the server.
+
+## Safety Options
+
+- `-ExpectedVersion X.Y.Z` prevents deploying an unintended `pubspec.yaml`
+ version.
+- The script rejects a release version that is not newer than the public
+ AppCast version.
+- `-Force` bypasses that version guard. Use it only to repeat an already
+ published version after confirming that replacing the artifact is intended.
+- `-SkipChecks` skips dependency resolution, formatting, analysis, and tests.
+ It should not be used for a normal production release.
+- `-FlutterCommand`, `-DartCommand`, `-InnoSetupCommand`, and
+ `-OpenSslCommand` override tool locations when they are not on `PATH`.
+
+Run `Get-Help .\tool\deploy_launcher.ps1 -Full` or inspect the parameter block
+for all endpoint, bucket, key, and signing-path overrides.
+
+## Failure Recovery
+
+The script stops on the first failed command.
+
+- Failure before S3 upload leaves production unchanged.
+- Failure after S3 upload but before AppCast publication leaves clients on the
+ preceding AppCast. Fix the issue and rerun the same release.
+- Failure after AppCast publication requires checking both public URLs and the
+ DSA signature before using `-Force`.
+- Never publish AppCast manually before the installer is publicly downloadable
+ and its SHA-256 matches the local artifact.
+
+After deployment, verify:
+
+```powershell
+Invoke-WebRequest `
+ -Uri "https://moon-well.online/appcast.xml" `
+ -UseBasicParsing
+
+Invoke-WebRequest `
+ -Uri "https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe" `
+ -Method Head `
+ -UseBasicParsing
+```
+
+Rotate any infrastructure credential that was exposed in terminal logs, chat,
+or another non-secret channel.
diff --git a/lib/app/design_system/mw_authentication.dart b/lib/app/design_system/mw_authentication.dart
index 53c42d2..ad3286b 100644
--- a/lib/app/design_system/mw_authentication.dart
+++ b/lib/app/design_system/mw_authentication.dart
@@ -2,21 +2,39 @@ import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
+enum MwAuthenticationMode { login, registration }
+
class MwAuthenticationForm extends StatefulWidget {
const MwAuthenticationForm({
super.key,
required this.usernameController,
required this.passwordController,
required this.onSubmit,
+ this.emailController,
+ this.passwordConfirmationController,
+ this.inviteCodeController,
+ this.mode = MwAuthenticationMode.login,
+ this.onModeChanged,
+ this.termsAccepted = false,
+ this.onTermsChanged,
this.loading = false,
this.error,
+ this.success,
});
final TextEditingController usernameController;
final TextEditingController passwordController;
+ final TextEditingController? emailController;
+ final TextEditingController? passwordConfirmationController;
+ final TextEditingController? inviteCodeController;
final VoidCallback onSubmit;
+ final MwAuthenticationMode mode;
+ final ValueChanged? onModeChanged;
+ final bool termsAccepted;
+ final ValueChanged? onTermsChanged;
final bool loading;
final String? error;
+ final String? success;
@override
State createState() => _MwAuthenticationFormState();
@@ -28,6 +46,7 @@ class _MwAuthenticationFormState extends State {
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
+ final registering = widget.mode == MwAuthenticationMode.registration;
return AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -35,13 +54,13 @@ class _MwAuthenticationFormState extends State {
const MwBrandMark(width: 210, compact: true),
const SizedBox(height: 28),
Text(
- 'ВХОД',
+ registering ? 'РЕГИСТРАЦИЯ' : 'ВХОД',
style: TextStyle(color: accent, fontSize: 10, letterSpacing: 2.4),
),
const SizedBox(height: 10),
- const Text(
- 'С возвращением',
- style: TextStyle(
+ Text(
+ registering ? 'Создать аккаунт' : 'С возвращением',
+ style: const TextStyle(
fontFamily: 'Cinzel',
color: Color(0xFFECE4D0),
fontSize: 30,
@@ -49,9 +68,11 @@ class _MwAuthenticationFormState extends State {
),
),
const SizedBox(height: 7),
- const Text(
- 'Войди в свой аккаунт, чтобы продолжить путь.',
- style: TextStyle(
+ Text(
+ registering
+ ? 'Зарегистрируй игровой аккаунт MoonWell.'
+ : 'Войди в свой аккаунт, чтобы продолжить путь.',
+ style: const TextStyle(
fontFamily: 'Cormorant Garamond',
fontStyle: FontStyle.italic,
color: Color(0x80ECE4D0),
@@ -61,12 +82,30 @@ class _MwAuthenticationFormState extends State {
const SizedBox(height: 22),
Row(
children: [
- _Tab(label: 'ВХОД', selected: true, accent: accent),
- _Tab(label: 'РЕГИСТРАЦИЯ', accent: accent),
+ _Tab(
+ label: 'ВХОД',
+ selected: !registering,
+ accent: accent,
+ onTap: widget.loading
+ ? null
+ : () => widget.onModeChanged?.call(
+ MwAuthenticationMode.login,
+ ),
+ ),
+ _Tab(
+ label: 'РЕГИСТРАЦИЯ',
+ selected: registering,
+ accent: accent,
+ onTap: widget.loading
+ ? null
+ : () => widget.onModeChanged?.call(
+ MwAuthenticationMode.registration,
+ ),
+ ),
],
),
const SizedBox(height: 18),
- _FieldLabel(text: 'ЛОГИН ИЛИ EMAIL'),
+ _FieldLabel(text: registering ? 'ЛОГИН' : 'ЛОГИН ИЛИ EMAIL'),
const SizedBox(height: 7),
TextField(
controller: widget.usernameController,
@@ -75,6 +114,19 @@ class _MwAuthenticationFormState extends State {
autofillHints: const [AutofillHints.username],
decoration: const InputDecoration(hintText: 'Aranthel'),
),
+ if (registering) ...[
+ const SizedBox(height: 14),
+ const _FieldLabel(text: 'EMAIL'),
+ const SizedBox(height: 7),
+ TextField(
+ controller: widget.emailController,
+ enabled: !widget.loading,
+ keyboardType: TextInputType.emailAddress,
+ textInputAction: TextInputAction.next,
+ autofillHints: const [AutofillHints.email],
+ decoration: const InputDecoration(hintText: 'player@example.com'),
+ ),
+ ],
const SizedBox(height: 14),
_FieldLabel(text: 'ПАРОЛЬ'),
const SizedBox(height: 7),
@@ -82,10 +134,35 @@ class _MwAuthenticationFormState extends State {
controller: widget.passwordController,
enabled: !widget.loading,
obscureText: true,
- textInputAction: TextInputAction.done,
- autofillHints: const [AutofillHints.password],
- onSubmitted: (_) => widget.onSubmit(),
+ textInputAction: registering
+ ? TextInputAction.next
+ : TextInputAction.done,
+ autofillHints: [
+ registering ? AutofillHints.newPassword : AutofillHints.password,
+ ],
+ onSubmitted: registering ? null : (_) => widget.onSubmit(),
),
+ if (registering) ...[
+ const SizedBox(height: 14),
+ const _FieldLabel(text: 'ПОВТОРИТЕ ПАРОЛЬ'),
+ const SizedBox(height: 7),
+ TextField(
+ controller: widget.passwordConfirmationController,
+ enabled: !widget.loading,
+ obscureText: true,
+ textInputAction: TextInputAction.next,
+ autofillHints: const [AutofillHints.newPassword],
+ ),
+ const SizedBox(height: 14),
+ const _FieldLabel(text: 'ИНВАЙТ-КОД (НЕОБЯЗАТЕЛЬНО)'),
+ const SizedBox(height: 7),
+ TextField(
+ controller: widget.inviteCodeController,
+ enabled: !widget.loading,
+ textInputAction: TextInputAction.done,
+ onSubmitted: (_) => widget.onSubmit(),
+ ),
+ ],
if (widget.error != null) ...[
const SizedBox(height: 10),
Text(
@@ -93,30 +170,61 @@ class _MwAuthenticationFormState extends State {
style: const TextStyle(color: Color(0xFFD76A78), fontSize: 12),
),
],
+ if (widget.success != null) ...[
+ const SizedBox(height: 10),
+ Text(
+ widget.success!,
+ style: const TextStyle(color: Color(0xFF6FC28A), fontSize: 12),
+ ),
+ ],
const SizedBox(height: 8),
- Row(
- children: [
- SizedBox(
- width: 24,
- child: Checkbox(
- value: _remember,
- onChanged: widget.loading
- ? null
- : (value) => setState(() => _remember = value ?? false),
+ if (registering)
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SizedBox(
+ width: 24,
+ child: Checkbox(
+ value: widget.termsAccepted,
+ onChanged: widget.loading
+ ? null
+ : (value) =>
+ widget.onTermsChanged?.call(value ?? false),
+ ),
),
- ),
- const SizedBox(width: 8),
- const Text(
- 'Запомнить меня',
- style: TextStyle(color: Color(0x9AECE4D0), fontSize: 11),
- ),
- const Spacer(),
- Text(
- 'Забыли пароль?',
- style: TextStyle(color: accent, fontSize: 11),
- ),
- ],
- ),
+ const SizedBox(width: 8),
+ const Expanded(
+ child: Text(
+ 'Я принимаю правила сервера и пользовательское соглашение',
+ style: TextStyle(color: Color(0x9AECE4D0), fontSize: 11),
+ ),
+ ),
+ ],
+ )
+ else
+ Row(
+ children: [
+ SizedBox(
+ width: 24,
+ child: Checkbox(
+ value: _remember,
+ onChanged: widget.loading
+ ? null
+ : (value) => setState(() => _remember = value ?? false),
+ ),
+ ),
+ const SizedBox(width: 8),
+ const Text(
+ 'Запомнить меня',
+ style: TextStyle(color: Color(0x9AECE4D0), fontSize: 11),
+ ),
+ const Spacer(),
+ Text(
+ 'Забыли пароль?',
+ style: TextStyle(color: accent, fontSize: 11),
+ ),
+ ],
+ ),
const SizedBox(height: 18),
SizedBox(
height: 54,
@@ -134,18 +242,18 @@ class _MwAuthenticationFormState extends State {
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
- : const Row(
+ : Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
- 'ВОЙТИ',
- style: TextStyle(
+ registering ? 'СОЗДАТЬ АККАУНТ' : 'ВОЙТИ',
+ style: const TextStyle(
fontFamily: 'Cinzel',
letterSpacing: 2.5,
),
),
- SizedBox(width: 14),
- Icon(Icons.arrow_forward, size: 18),
+ const SizedBox(width: 14),
+ const Icon(Icons.arrow_forward, size: 18),
],
),
),
@@ -153,7 +261,7 @@ class _MwAuthenticationFormState extends State {
const SizedBox(height: 28),
const Center(
child: Text(
- 'MOONWELL · V 1.0.0 · LOGIN.MOONWELL.GG',
+ 'MOONWELL · V 1.0.0 · PLAY.MOON-WELL.ONLINE',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: Color(0x52ECE4D0),
@@ -187,30 +295,35 @@ class _Tab extends StatelessWidget {
const _Tab({
required this.label,
required this.accent,
+ this.onTap,
this.selected = false,
});
final String label;
final Color accent;
+ final VoidCallback? onTap;
final bool selected;
@override
Widget build(BuildContext context) => Expanded(
- child: Container(
- height: 38,
- alignment: Alignment.center,
- decoration: BoxDecoration(
- border: Border(
- bottom: BorderSide(
- color: selected ? accent : const Color(0x247FB8D4),
+ child: InkWell(
+ onTap: onTap,
+ child: Container(
+ height: 38,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ border: Border(
+ bottom: BorderSide(
+ color: selected ? accent : const Color(0x247FB8D4),
+ ),
),
),
- ),
- child: Text(
- label,
- style: TextStyle(
- fontFamily: 'Cinzel',
- color: selected ? const Color(0xFFECE4D0) : const Color(0x80ECE4D0),
- fontSize: 9,
- letterSpacing: 1.5,
+ child: Text(
+ label,
+ style: TextStyle(
+ fontFamily: 'Cinzel',
+ color: selected ? const Color(0xFFECE4D0) : const Color(0x80ECE4D0),
+ fontSize: 9,
+ letterSpacing: 1.5,
+ ),
),
),
),
diff --git a/lib/app/design_system/mw_launcher_components.dart b/lib/app/design_system/mw_launcher_components.dart
index be8c7eb..0aa7e59 100644
--- a/lib/app/design_system/mw_launcher_components.dart
+++ b/lib/app/design_system/mw_launcher_components.dart
@@ -99,7 +99,7 @@ class MwGameTile extends StatelessWidget {
],
),
),
- if (!enabled)
+ if (!enabled && subtitle == null)
const Text(
'SOON',
style: TextStyle(
@@ -121,13 +121,24 @@ class MwGameTile extends StatelessWidget {
}
class MwGameRail extends StatelessWidget {
- const MwGameRail({super.key, this.compact = false});
+ const MwGameRail({
+ super.key,
+ this.compact = false,
+ this.onSettings,
+ this.username,
+ });
final bool compact;
+ final VoidCallback? onSettings;
+ final String? username;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
+ final normalizedUsername = username?.trim() ?? '';
+ final userInitial = normalizedUsername.isEmpty
+ ? '?'
+ : normalizedUsername.characters.first.toUpperCase();
return Container(
padding: const EdgeInsets.fromLTRB(14, 48, 14, 30),
decoration: const BoxDecoration(
@@ -154,7 +165,10 @@ class MwGameRail extends StatelessWidget {
BoxShadow(color: accent.withAlpha(70), blurRadius: 12),
],
),
- child: const Text('A', style: TextStyle(fontFamily: 'Cinzel')),
+ child: Text(
+ userInitial,
+ style: const TextStyle(fontFamily: 'Cinzel'),
+ ),
),
const SizedBox(height: 14),
const Divider(color: _border),
@@ -183,28 +197,13 @@ class MwGameRail extends StatelessWidget {
onPressed: () {},
),
const SizedBox(height: 8),
- const MwGameTile(
+ MwGameTile(
title: 'MoonWell Karts',
- subtitle: 'Closed Alpha',
+ subtitle: 'В разработке',
mark: 'K',
icon: Icons.sports_motorsports,
enabled: false,
- ),
- const SizedBox(height: 8),
- const MwGameTile(
- title: 'Tides of Elune',
- subtitle: 'В разработке',
- mark: 'T',
- icon: Icons.style,
- enabled: false,
- ),
- const SizedBox(height: 8),
- const MwGameTile(
- title: 'Project Verge',
- subtitle: 'Анонс',
- mark: 'V',
- icon: Icons.auto_awesome,
- enabled: false,
+ compact: compact,
),
const Spacer(),
const Divider(color: _border),
@@ -216,12 +215,15 @@ class MwGameRail extends StatelessWidget {
icon: const Icon(Icons.people_outline, size: 18),
),
const Spacer(),
- Icon(
- Icons.settings_outlined,
- color: accent.withAlpha(150),
- size: 18,
+ IconButton(
+ onPressed: onSettings,
+ tooltip: 'Настройки',
+ icon: Icon(
+ Icons.settings_outlined,
+ color: accent.withAlpha(150),
+ size: 18,
+ ),
),
- const SizedBox(width: 12),
],
),
],
@@ -236,11 +238,13 @@ class MwNewsItemData {
required this.body,
this.createdAt,
this.imageUrl,
+ this.onPressed,
});
final String title;
final String body;
final DateTime? createdAt;
final String? imageUrl;
+ final VoidCallback? onPressed;
}
class MwNewsItem extends StatelessWidget {
@@ -251,55 +255,64 @@ class MwNewsItem extends StatelessWidget {
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
final date = item.createdAt?.toLocal();
- return Padding(
- padding: const EdgeInsets.fromLTRB(0, 13, 12, 13),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
+ return Material(
+ color: Colors.transparent,
+ child: InkWell(
+ onTap: item.onPressed,
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(0, 13, 12, 13),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Container(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
- decoration: BoxDecoration(
- border: Border.all(color: accent),
- borderRadius: BorderRadius.circular(3),
- ),
- child: Text(
- 'НОВОСТЬ',
- style: TextStyle(
- fontFamily: 'JetBrains Mono',
- color: accent,
- fontSize: 8,
- letterSpacing: 1.5,
+ Row(
+ children: [
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8,
+ vertical: 3,
+ ),
+ decoration: BoxDecoration(
+ border: Border.all(color: accent),
+ borderRadius: BorderRadius.circular(3),
+ ),
+ child: Text(
+ 'НОВОСТЬ',
+ style: TextStyle(
+ fontFamily: 'JetBrains Mono',
+ color: accent,
+ fontSize: 8,
+ letterSpacing: 1.5,
+ ),
+ ),
),
- ),
+ const Spacer(),
+ Text(
+ date == null
+ ? 'СЕГОДНЯ'
+ : '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}',
+ style: const TextStyle(
+ fontFamily: 'JetBrains Mono',
+ color: _muted,
+ fontSize: 9,
+ ),
+ ),
+ ],
),
- const Spacer(),
+ const SizedBox(height: 7),
Text(
- date == null
- ? 'СЕГОДНЯ'
- : '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}',
+ item.title,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
style: const TextStyle(
- fontFamily: 'JetBrains Mono',
- color: _muted,
- fontSize: 9,
+ fontFamily: 'Cormorant Garamond',
+ color: _ivory,
+ fontSize: 16,
+ height: 1.25,
),
),
],
),
- const SizedBox(height: 7),
- Text(
- item.title,
- maxLines: 2,
- overflow: TextOverflow.ellipsis,
- style: const TextStyle(
- fontFamily: 'Cormorant Garamond',
- color: _ivory,
- fontSize: 16,
- height: 1.25,
- ),
- ),
- ],
+ ),
),
);
}
@@ -337,8 +350,6 @@ class MwNewsList extends StatelessWidget {
child: Row(
children: [
_NewsTab(label: 'НОВОСТИ', selected: true, accent: accent),
- _NewsTab(label: 'СОБЫТИЯ', accent: accent),
- _NewsTab(label: 'ПАТЧИ', accent: accent),
],
),
),
@@ -353,7 +364,7 @@ class MwNewsList extends StatelessWidget {
child: Row(
children: [
const Text(
- 'MOONWELL.GG',
+ 'MOON-WELL.ONLINE',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
@@ -455,6 +466,9 @@ class MwSyncActionArea extends StatelessWidget {
this.progress,
this.currentPath,
this.error,
+ this.realmName,
+ this.realmOnline,
+ this.realmGameBuild,
});
final MwSyncPresentation state;
final String status;
@@ -463,6 +477,9 @@ class MwSyncActionArea extends StatelessWidget {
final double? progress;
final String? currentPath;
final String? error;
+ final String? realmName;
+ final bool? realmOnline;
+ final int? realmGameBuild;
@override
Widget build(BuildContext context) {
@@ -477,6 +494,14 @@ class MwSyncActionArea extends StatelessWidget {
final value = ready || state == MwSyncPresentation.launched
? 1.0
: (progress ?? 0);
+ final realmStatus = switch (realmOnline) {
+ true => 'ONLINE',
+ false => 'OFFLINE',
+ null => 'STATUS UNKNOWN',
+ };
+ final realmMetadata = realmGameBuild != null && realmGameBuild! > 0
+ ? '$realmStatus · BUILD $realmGameBuild'
+ : realmStatus;
final label = switch (state) {
MwSyncPresentation.install => 'УСТАНОВИТЬ',
MwSyncPresentation.paused => 'ПРОДОЛЖИТЬ',
@@ -503,26 +528,28 @@ class MwSyncActionArea extends StatelessWidget {
border: Border.all(color: _border),
borderRadius: BorderRadius.circular(6),
),
- child: const Row(
+ child: Row(
children: [
- _PulseDot(),
- SizedBox(width: 12),
+ _PulseDot(online: realmOnline),
+ const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
- "ELUNE'S GRACE",
- style: TextStyle(
+ (realmName?.trim().isNotEmpty ?? false)
+ ? realmName!.toUpperCase()
+ : 'REALM UNAVAILABLE',
+ style: const TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 11,
letterSpacing: 1.5,
),
),
- SizedBox(height: 3),
+ const SizedBox(height: 3),
Text(
- 'ONLINE · EU',
- style: TextStyle(
+ realmMetadata,
+ style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
@@ -592,14 +619,20 @@ class MwSyncActionArea extends StatelessWidget {
),
),
),
- Text(
- ready ? 'Последняя проверка: только что' : '',
- style: const TextStyle(
- fontFamily: 'JetBrains Mono',
- color: _muted,
- fontSize: 8,
+ if (ready)
+ const Flexible(
+ child: Text(
+ 'Последняя проверка: только что',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ textAlign: TextAlign.right,
+ style: TextStyle(
+ fontFamily: 'JetBrains Mono',
+ color: _muted,
+ fontSize: 8,
+ ),
+ ),
),
- ),
],
),
],
@@ -647,17 +680,27 @@ class MwSyncActionArea extends StatelessWidget {
}
class _PulseDot extends StatelessWidget {
- const _PulseDot();
+ const _PulseDot({required this.online});
+
+ final bool? online;
+
@override
- Widget build(BuildContext context) => Container(
- width: 8,
- height: 8,
- decoration: const BoxDecoration(
- color: Color(0xFF6FC28A),
- shape: BoxShape.circle,
- boxShadow: [BoxShadow(color: Color(0xAA6FC28A), blurRadius: 8)],
- ),
- );
+ Widget build(BuildContext context) {
+ final color = switch (online) {
+ true => const Color(0xFF6FC28A),
+ false => const Color(0xFFD76A78),
+ null => _muted,
+ };
+ return Container(
+ width: 8,
+ height: 8,
+ decoration: BoxDecoration(
+ color: color,
+ shape: BoxShape.circle,
+ boxShadow: [BoxShadow(color: color.withAlpha(170), blurRadius: 8)],
+ ),
+ );
+ }
}
class MwLauncherStatusBar extends StatelessWidget {
@@ -709,87 +752,67 @@ class MwLauncherStatusBar extends StatelessWidget {
}
class MwAccountAffordance extends StatelessWidget {
- const MwAccountAffordance({
- super.key,
- required this.onLogout,
- this.onSettings,
- });
+ const MwAccountAffordance({super.key, required this.onLogout, this.username});
final VoidCallback onLogout;
- final VoidCallback? onSettings;
+ final String? username;
@override
- Widget build(BuildContext context) => PopupMenuButton(
- tooltip: 'Аккаунт',
- onSelected: (value) =>
- value == 'settings' ? onSettings?.call() : onLogout(),
- itemBuilder: (_) => const [
- PopupMenuItem(value: 'settings', child: Text('Настройки')),
- PopupMenuItem(value: 'logout', child: Text('Выйти')),
- ],
- child: Container(
- padding: const EdgeInsets.fromLTRB(8, 7, 15, 7),
- decoration: BoxDecoration(
- color: const Color(0x99070A18),
- border: Border.all(color: _border),
- borderRadius: BorderRadius.circular(99),
- ),
- child: const Row(
- children: [
- CircleAvatar(
- radius: 18,
- backgroundColor: Color(0xFF5B3A8A),
- child: Text(
- 'Æ',
- style: TextStyle(fontFamily: 'Cinzel', fontSize: 12),
+ Widget build(BuildContext context) {
+ final displayUsername = (username?.trim().isNotEmpty ?? false)
+ ? username!.trim()
+ : 'Аккаунт';
+ final initial = displayUsername.characters.first.toUpperCase();
+
+ return PopupMenuButton(
+ tooltip: 'Аккаунт',
+ onSelected: (_) => onLogout(),
+ itemBuilder: (_) => const [
+ PopupMenuItem(value: 'logout', child: Text('Выйти')),
+ ],
+ child: Container(
+ padding: const EdgeInsets.fromLTRB(8, 7, 15, 7),
+ decoration: BoxDecoration(
+ color: const Color(0x99070A18),
+ border: Border.all(color: _border),
+ borderRadius: BorderRadius.circular(99),
+ ),
+ child: Row(
+ children: [
+ CircleAvatar(
+ radius: 18,
+ backgroundColor: const Color(0xFF5B3A8A),
+ child: Text(
+ initial,
+ style: const TextStyle(fontFamily: 'Cinzel', fontSize: 12),
+ ),
),
- ),
- SizedBox(width: 11),
- Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- 'Aranthel',
- style: TextStyle(
- fontFamily: 'Cinzel',
- color: _ivory,
- fontSize: 12,
- letterSpacing: 1,
- ),
+ const SizedBox(width: 11),
+ Text(
+ displayUsername,
+ style: const TextStyle(
+ fontFamily: 'Cinzel',
+ color: _ivory,
+ fontSize: 12,
+ letterSpacing: 1,
),
- SizedBox(height: 2),
- Text(
- 'ХРАНИТЕЛЬ РОЩИ',
- style: TextStyle(
- fontFamily: 'JetBrains Mono',
- color: _muted,
- fontSize: 7,
- letterSpacing: 1.1,
- ),
- ),
- ],
- ),
- ],
+ ),
+ ],
+ ),
),
- ),
- );
+ );
+ }
}
class MwSettingsPanel extends StatelessWidget {
const MwSettingsPanel({
super.key,
required this.installationPath,
- required this.themeVariant,
required this.onChooseDirectory,
- required this.onThemeChanged,
required this.onVerify,
- required this.onLogout,
this.syncActive = false,
});
final String? installationPath;
- final MoonWellThemeVariant themeVariant;
final VoidCallback onChooseDirectory;
- final ValueChanged onThemeChanged;
final VoidCallback onVerify;
- final VoidCallback onLogout;
final bool syncActive;
@override
Widget build(BuildContext context) => MwPanel(
@@ -812,32 +835,11 @@ class MwSettingsPanel extends StatelessWidget {
variant: MwButtonVariant.secondary,
),
const SizedBox(height: 20),
- SegmentedButton(
- segments: const [
- ButtonSegment(
- value: MoonWellThemeVariant.forest,
- label: Text('Лес'),
- ),
- ButtonSegment(
- value: MoonWellThemeVariant.temple,
- label: Text('Храм'),
- ),
- ],
- selected: {themeVariant},
- onSelectionChanged: (value) => onThemeChanged(value.single),
- ),
- const SizedBox(height: 20),
MwButton(
label: 'Проверить файлы',
onPressed: syncActive ? null : onVerify,
variant: MwButtonVariant.secondary,
),
- const SizedBox(height: 8),
- MwButton(
- label: 'Выйти',
- onPressed: onLogout,
- variant: MwButtonVariant.destructive,
- ),
],
),
);
diff --git a/lib/app/design_system/mw_shells.dart b/lib/app/design_system/mw_shells.dart
index 3975010..3d4fc15 100644
--- a/lib/app/design_system/mw_shells.dart
+++ b/lib/app/design_system/mw_shells.dart
@@ -20,40 +20,44 @@ class MwBrandMark extends StatelessWidget {
label: 'MoonWell',
child: SizedBox(
width: width,
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- _MoonMark(size: compact ? 28 : 40, color: accent),
- const SizedBox(width: 12),
- Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- 'MOONWELL',
- style: TextStyle(
- fontFamily: 'Cinzel',
- color: _ivory,
- fontSize: compact ? 15 : 22,
- height: 1,
- letterSpacing: compact ? 4 : 6,
- ),
- ),
- if (!compact) ...[
- const SizedBox(height: 5),
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ alignment: Alignment.centerLeft,
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _MoonMark(size: compact ? 28 : 40, color: accent),
+ const SizedBox(width: 12),
+ Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
Text(
- 'LUNAR · LAUNCHER',
+ 'MOONWELL',
style: TextStyle(
- fontFamily: 'JetBrains Mono',
- color: accent,
- fontSize: 9,
- letterSpacing: 2.6,
+ fontFamily: 'Cinzel',
+ color: _ivory,
+ fontSize: compact ? 15 : 22,
+ height: 1,
+ letterSpacing: compact ? 4 : 6,
),
),
+ if (!compact) ...[
+ const SizedBox(height: 5),
+ Text(
+ 'LUNAR · LAUNCHER',
+ style: TextStyle(
+ fontFamily: 'JetBrains Mono',
+ color: accent,
+ fontSize: 9,
+ letterSpacing: 2.6,
+ ),
+ ),
+ ],
],
- ],
- ),
- ],
+ ),
+ ],
+ ),
),
),
);
@@ -165,50 +169,89 @@ class MwLoginShell extends StatelessWidget {
child: Row(
children: [
Expanded(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(64, 80, 64, 58),
- child: Align(
- alignment: Alignment.bottomLeft,
- child: ConstrainedBox(
- constraints: const BoxConstraints(maxWidth: 500),
- child: const Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- '✦',
- style: TextStyle(
- color: Color(0xFF7FB8D4),
- fontSize: 20,
- ),
- ),
- SizedBox(height: 12),
- Text(
- '«Звёздный свет проведёт тебя сквозь самые '
- 'тёмные ночи — если ты готов идти туда, '
- 'куда он указывает.»',
- style: TextStyle(
- fontFamily: 'Cormorant Garamond',
- fontStyle: FontStyle.italic,
- color: Color(0xC6ECE4D0),
- fontSize: 22,
- height: 1.5,
- ),
- ),
- SizedBox(height: 18),
- Text(
- '— МАЛФУРИОН ЯРОСТЬ БУРИ',
- style: TextStyle(
- fontFamily: 'JetBrains Mono',
- color: Color(0x80ECE4D0),
- fontSize: 10,
- letterSpacing: 2,
- ),
- ),
- ],
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ Image.asset(
+ 'assets/login_key_art.png',
+ package: assetPackage,
+ fit: BoxFit.cover,
+ alignment: Alignment.center,
+ errorBuilder: (_, _, _) => const SizedBox.shrink(),
+ ),
+ const DecoratedBox(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ stops: [0, .52, 1],
+ colors: [
+ Color(0x00070A18),
+ Color(0x22070A18),
+ Color(0xF2070A18),
+ ],
+ ),
),
),
- ),
+ const DecoratedBox(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.centerLeft,
+ end: Alignment.centerRight,
+ colors: [
+ Color(0x33070A18),
+ Color(0x00070A18),
+ Color(0x8F070A18),
+ ],
+ ),
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.fromLTRB(64, 80, 64, 58),
+ child: Align(
+ alignment: Alignment.bottomLeft,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 500),
+ child: const Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ '✦',
+ style: TextStyle(
+ color: Color(0xFF7FB8D4),
+ fontSize: 20,
+ ),
+ ),
+ SizedBox(height: 12),
+ Text(
+ '«Звёздный свет проведёт тебя сквозь самые '
+ 'тёмные ночи — если ты готов идти туда, '
+ 'куда он указывает.»',
+ style: TextStyle(
+ fontFamily: 'Cormorant Garamond',
+ fontStyle: FontStyle.italic,
+ color: Color(0xC6ECE4D0),
+ fontSize: 22,
+ height: 1.5,
+ ),
+ ),
+ SizedBox(height: 18),
+ Text(
+ '— МАЛФУРИОН ЯРОСТЬ БУРИ',
+ style: TextStyle(
+ fontFamily: 'JetBrains Mono',
+ color: Color(0x80ECE4D0),
+ fontSize: 10,
+ letterSpacing: 2,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
),
),
Container(
@@ -292,6 +335,8 @@ class MwLauncherShell extends StatelessWidget {
required this.onMinimize,
required this.onMaximizeRestore,
required this.onClose,
+ this.onSettings,
+ this.username,
this.assetPackage,
});
@@ -304,6 +349,8 @@ class MwLauncherShell extends StatelessWidget {
final VoidCallback onMinimize;
final VoidCallback onMaximizeRestore;
final VoidCallback onClose;
+ final VoidCallback? onSettings;
+ final String? username;
final String? assetPackage;
@override
@@ -313,12 +360,57 @@ class MwLauncherShell extends StatelessWidget {
assetPackage: assetPackage,
child: Stack(
children: [
- const Positioned(
+ Positioned(
+ left: 240,
+ right: 0,
+ top: 0,
+ bottom: 26,
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ Image.asset(
+ 'assets/launcher_key_art.png',
+ package: assetPackage,
+ fit: BoxFit.cover,
+ alignment: Alignment.center,
+ errorBuilder: (_, _, _) => const SizedBox.shrink(),
+ ),
+ const DecoratedBox(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ stops: [0, .3, .7, 1],
+ colors: [
+ Color(0xF2070A18),
+ Color(0xB8070A18),
+ Color(0x22070A18),
+ Color(0x73070A18),
+ ],
+ ),
+ ),
+ ),
+ const DecoratedBox(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ stops: [0, .58, 1],
+ colors: [
+ Color(0x52070A18),
+ Color(0x00070A18),
+ Color(0xD9070A18),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ Positioned(
left: 0,
top: 0,
bottom: 0,
width: 240,
- child: MwGameRail(),
+ child: MwGameRail(onSettings: onSettings, username: username),
),
Positioned(
left: 272,
diff --git a/lib/app/home_screen/bloc/home_screen_bloc.dart b/lib/app/home_screen/bloc/home_screen_bloc.dart
index 5a99014..4789a71 100644
--- a/lib/app/home_screen/bloc/home_screen_bloc.dart
+++ b/lib/app/home_screen/bloc/home_screen_bloc.dart
@@ -26,11 +26,14 @@ class HomeScreenBloc extends Bloc {
required PreferencesRepository preferencesRepository,
required LauncherSession session,
required ClientManifest manifest,
+ Duration manifestRefreshInterval = const Duration(minutes: 5),
+ Duration realmRefreshInterval = const Duration(minutes: 1),
}) : _clientSyncUseCase = clientSyncUseCase,
_gameInstallationService = gameInstallationService,
_launcherApiClient = launcherApiClient,
_preferencesRepository = preferencesRepository,
_session = session,
+ _startupManifest = manifest,
super(
HomeScreenState(
model: HomeScreenModel.initial().copyWith(
@@ -41,14 +44,28 @@ class HomeScreenBloc extends Bloc {
) {
on(_onHomeScreenLoad);
on(_onHomeScreenSyncRequested);
+ on(
+ _onHomeScreenManifestRefreshRequested,
+ );
+ on(_onHomeScreenRealmRefreshRequested);
+ on(_onHomeScreenAccountLoadRequested);
on(_onHomeScreenOutputDirRequested);
on(_onHomeScreenPauseRequested);
on(_onHomeScreenPlayRequested);
+ on(_onHomeScreenGameExited);
on(_onHomeScreenLogoutRequested);
on(_onHomeScreenSyncStatusChanged);
on(_onHomeScreenSyncFailed);
add(HomeScreenLoad());
+ _manifestRefreshTimer = Timer.periodic(
+ manifestRefreshInterval,
+ (_) => add(HomeScreenManifestRefreshRequested()),
+ );
+ _realmRefreshTimer = Timer.periodic(
+ realmRefreshInterval,
+ (_) => add(HomeScreenRealmRefreshRequested()),
+ );
}
final ClientSyncUseCase _clientSyncUseCase;
@@ -58,6 +75,10 @@ class HomeScreenBloc extends Bloc {
StreamSubscription? _syncSubscription;
final LauncherSession _session;
+ final ClientManifest _startupManifest;
+ Timer? _manifestRefreshTimer;
+ Timer? _realmRefreshTimer;
+ int? _activeGamePid;
bool _pauseRequested = false;
Future _onHomeScreenLoad(
@@ -82,6 +103,12 @@ class HomeScreenBloc extends Bloc {
),
);
+ if (outputPath != null) {
+ add(HomeScreenSyncRequested(manifest: _startupManifest));
+ }
+ add(HomeScreenRealmRefreshRequested());
+ add(HomeScreenAccountLoadRequested());
+
try {
final newsItems = await _launcherApiClient.fetchNews(
_session.accessToken,
@@ -107,11 +134,101 @@ class HomeScreenBloc extends Bloc {
}
}
+ Future _onHomeScreenAccountLoadRequested(
+ HomeScreenAccountLoadRequested event,
+ Emitter emit,
+ ) async {
+ if (!state.model.isAuthenticated) {
+ return;
+ }
+
+ try {
+ final account = await _launcherApiClient.fetchAccount(
+ _session.accessToken,
+ );
+ if (!state.model.isAuthenticated) {
+ return;
+ }
+ emit(
+ HomeScreenState(
+ model: state.model.copyWith(
+ accountUsername: account.username.isEmpty ? null : account.username,
+ ),
+ ),
+ );
+ } catch (_) {
+ // Account metadata does not block patching or launching the game.
+ }
+ }
+
+ Future _onHomeScreenRealmRefreshRequested(
+ HomeScreenRealmRefreshRequested event,
+ Emitter emit,
+ ) async {
+ if (!state.model.isAuthenticated) {
+ return;
+ }
+
+ try {
+ final realms = await _launcherApiClient.fetchRealms();
+ if (!state.model.isAuthenticated) {
+ return;
+ }
+ emit(
+ HomeScreenState(
+ model: state.model.copyWith(
+ realm: realms.isEmpty ? null : realms.first,
+ ),
+ ),
+ );
+ } catch (_) {
+ // Keep the last known realm state and retry on the next timer tick.
+ }
+ }
+
+ Future _onHomeScreenManifestRefreshRequested(
+ HomeScreenManifestRefreshRequested event,
+ Emitter emit,
+ ) async {
+ if (!state.model.isAuthenticated ||
+ state.model.isSyncing ||
+ state.model.isGameRunning ||
+ state.model.phase == HomeScreenPhase.paused ||
+ state.model.outputPath == null) {
+ return;
+ }
+
+ try {
+ final manifest = await _launcherApiClient.fetchManifest(
+ _session.accessToken,
+ );
+
+ if (state.model.isSyncing || !state.model.isAuthenticated) {
+ return;
+ }
+
+ if (manifest.buildHash == state.model.localBuildHash) {
+ if (manifest.buildHash != state.model.remoteBuildHash) {
+ emit(
+ HomeScreenState(
+ model: state.model.copyWith(remoteBuildHash: manifest.buildHash),
+ ),
+ );
+ }
+ return;
+ }
+
+ add(HomeScreenSyncRequested(manifest: manifest));
+ } catch (_) {
+ // Retry on the next timer tick without replacing the stable UI state.
+ }
+ }
+
Future _onHomeScreenSyncRequested(
HomeScreenSyncRequested event,
Emitter emit,
) async {
- if (state.model.isSyncing) {
+ if (state.model.isSyncing || state.model.isGameRunning) {
return;
}
@@ -145,6 +262,7 @@ class HomeScreenBloc extends Bloc {
request: ClientSyncRequest(
installationDir: outputPath.toFilePath(),
accessToken: _session.accessToken,
+ manifest: event.manifest,
),
isCancelled: () async => _pauseRequested,
),
@@ -228,12 +346,30 @@ class HomeScreenBloc extends Bloc {
try {
final installationDir = outputPath.toFilePath();
await _gameInstallationService.clearCache(installationDir);
- await _gameInstallationService.launchGame(installationDir);
+ final process = await _gameInstallationService.launchGame(
+ installationDir,
+ );
+ _activeGamePid = process.pid;
+ unawaited(
+ process.exitCode.then(
+ (exitCode) {
+ if (!isClosed) {
+ add(HomeScreenGameExited(pid: process.pid, exitCode: exitCode));
+ }
+ },
+ onError: (_) {
+ if (!isClosed) {
+ add(HomeScreenGameExited(pid: process.pid, exitCode: null));
+ }
+ },
+ ),
+ );
emit(
HomeScreenState(
model: state.model.copyWith(
phase: HomeScreenPhase.readyToPlay,
+ isGameRunning: true,
statusText: 'Игра запущена. Папка Cache очищена перед стартом.',
errorMessage: null,
),
@@ -249,11 +385,39 @@ class HomeScreenBloc extends Bloc {
}
}
+ Future _onHomeScreenGameExited(
+ HomeScreenGameExited event,
+ Emitter emit,
+ ) async {
+ if (_activeGamePid != event.pid) {
+ return;
+ }
+
+ _activeGamePid = null;
+ final hasClientExecutable = await _resolveExecutablePresence(
+ state.model.outputPath,
+ );
+ emit(
+ HomeScreenState(
+ model: state.model.copyWith(
+ phase: _stablePhase(hasClientExecutable),
+ hasClientExecutable: hasClientExecutable,
+ isGameRunning: false,
+ statusText: hasClientExecutable
+ ? 'Игра закрыта. Клиент готов к запуску.'
+ : 'Игра закрыта, но Wow.exe больше не найден.',
+ errorMessage: null,
+ ),
+ ),
+ );
+ }
+
Future _onHomeScreenLogoutRequested(
HomeScreenLogoutRequested event,
Emitter emit,
) async {
_pauseRequested = true;
+ _activeGamePid = null;
await _syncSubscription?.cancel();
_syncSubscription = null;
await _preferencesRepository.clearLauncherSession();
@@ -263,6 +427,8 @@ class HomeScreenBloc extends Bloc {
model: state.model.copyWith(
phase: HomeScreenPhase.idle,
isAuthenticated: false,
+ isGameRunning: false,
+ accountUsername: null,
progress: const DownloadProgress.initial(),
statusText: 'Сессия завершена. Войдите снова.',
currentPath: null,
@@ -274,6 +440,7 @@ class HomeScreenBloc extends Bloc {
newsItems: const [],
isLoadingNews: false,
newsErrorMessage: null,
+ realm: null,
),
),
);
@@ -424,6 +591,9 @@ class HomeScreenBloc extends Bloc {
@override
Future close() async {
_pauseRequested = true;
+ _activeGamePid = null;
+ _manifestRefreshTimer?.cancel();
+ _realmRefreshTimer?.cancel();
await _syncSubscription?.cancel();
return super.close();
}
diff --git a/lib/app/home_screen/bloc/home_screen_event.dart b/lib/app/home_screen/bloc/home_screen_event.dart
index 1805dd8..d48df0f 100644
--- a/lib/app/home_screen/bloc/home_screen_event.dart
+++ b/lib/app/home_screen/bloc/home_screen_event.dart
@@ -5,7 +5,17 @@ sealed class HomeScreenEvent {}
final class HomeScreenLoad extends HomeScreenEvent {}
-final class HomeScreenSyncRequested extends HomeScreenEvent {}
+final class HomeScreenSyncRequested extends HomeScreenEvent {
+ final ClientManifest? manifest;
+
+ HomeScreenSyncRequested({this.manifest});
+}
+
+final class HomeScreenManifestRefreshRequested extends HomeScreenEvent {}
+
+final class HomeScreenRealmRefreshRequested extends HomeScreenEvent {}
+
+final class HomeScreenAccountLoadRequested extends HomeScreenEvent {}
final class HomeScreenOutputDirRequested extends HomeScreenEvent {}
@@ -13,6 +23,13 @@ final class HomeScreenPauseRequested extends HomeScreenEvent {}
final class HomeScreenPlayRequested extends HomeScreenEvent {}
+final class HomeScreenGameExited extends HomeScreenEvent {
+ final int pid;
+ final int? exitCode;
+
+ HomeScreenGameExited({required this.pid, required this.exitCode});
+}
+
final class HomeScreenLogoutRequested extends HomeScreenEvent {}
final class HomeScreenSyncStatusChanged extends HomeScreenEvent {
diff --git a/lib/app/home_screen/bloc/home_screen_model.dart b/lib/app/home_screen/bloc/home_screen_model.dart
index 9a1a743..a32c470 100644
--- a/lib/app/home_screen/bloc/home_screen_model.dart
+++ b/lib/app/home_screen/bloc/home_screen_model.dart
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
+import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
enum HomeScreenPhase {
idle,
@@ -22,6 +23,8 @@ final class HomeScreenModel {
final HomeScreenPhase phase;
final bool isAuthenticated;
final bool hasClientExecutable;
+ final bool isGameRunning;
+ final String? accountUsername;
final String statusText;
final String? currentPath;
final String? errorMessage;
@@ -32,6 +35,7 @@ final class HomeScreenModel {
final List newsItems;
final bool isLoadingNews;
final String? newsErrorMessage;
+ final LauncherRealm? realm;
final ClientSyncStage? syncStage;
const HomeScreenModel({
@@ -40,6 +44,8 @@ final class HomeScreenModel {
required this.phase,
required this.isAuthenticated,
required this.hasClientExecutable,
+ required this.isGameRunning,
+ required this.accountUsername,
required this.statusText,
required this.currentPath,
required this.errorMessage,
@@ -50,6 +56,7 @@ final class HomeScreenModel {
required this.newsItems,
required this.isLoadingNews,
required this.newsErrorMessage,
+ required this.realm,
required this.syncStage,
});
@@ -59,6 +66,8 @@ final class HomeScreenModel {
phase = HomeScreenPhase.idle,
isAuthenticated = false,
hasClientExecutable = false,
+ isGameRunning = false,
+ accountUsername = null,
statusText = 'Загрузка...',
currentPath = null,
errorMessage = null,
@@ -69,6 +78,7 @@ final class HomeScreenModel {
newsItems = const [],
isLoadingNews = true,
newsErrorMessage = null,
+ realm = null,
syncStage = null;
bool get isBusy =>
@@ -77,7 +87,7 @@ final class HomeScreenModel {
bool get isSyncing => phase == HomeScreenPhase.syncing;
- bool get canPlay => hasClientExecutable && !isBusy;
+ bool get canPlay => hasClientExecutable && !isBusy && !isGameRunning;
HomeScreenModel copyWith({
DownloadProgress? progress,
@@ -85,6 +95,8 @@ final class HomeScreenModel {
HomeScreenPhase? phase,
bool? isAuthenticated,
bool? hasClientExecutable,
+ bool? isGameRunning,
+ Object? accountUsername = _sentinel,
String? statusText,
Object? currentPath = _sentinel,
Object? errorMessage = _sentinel,
@@ -95,6 +107,7 @@ final class HomeScreenModel {
List? newsItems,
bool? isLoadingNews,
Object? newsErrorMessage = _sentinel,
+ Object? realm = _sentinel,
Object? syncStage = _sentinel,
}) {
return HomeScreenModel(
@@ -105,6 +118,10 @@ final class HomeScreenModel {
phase: phase ?? this.phase,
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
hasClientExecutable: hasClientExecutable ?? this.hasClientExecutable,
+ isGameRunning: isGameRunning ?? this.isGameRunning,
+ accountUsername: accountUsername == _sentinel
+ ? this.accountUsername
+ : accountUsername as String?,
statusText: statusText ?? this.statusText,
currentPath: currentPath == _sentinel
? this.currentPath
@@ -125,6 +142,7 @@ final class HomeScreenModel {
newsErrorMessage: newsErrorMessage == _sentinel
? this.newsErrorMessage
: newsErrorMessage as String?,
+ realm: realm == _sentinel ? this.realm : realm as LauncherRealm?,
syncStage: syncStage == _sentinel
? this.syncStage
: syncStage as ClientSyncStage?,
diff --git a/lib/app/home_screen/home_screen_content.dart b/lib/app/home_screen/home_screen_content.dart
index 069dbc9..7ea6752 100644
--- a/lib/app/home_screen/home_screen_content.dart
+++ b/lib/app/home_screen/home_screen_content.dart
@@ -6,8 +6,10 @@ import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart'
import 'package:moonwell_launcher/app/design_system/mw_shells.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/mw_app.dart';
+import 'package:moonwell_launcher/config.dart';
+import 'package:moonwell_launcher/features/launcher/data/launcher_news_link.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
+import 'package:url_launcher/url_launcher.dart';
import 'package:window_manager/window_manager.dart';
class HomeScreenContent extends StatelessWidget {
@@ -17,14 +19,16 @@ class HomeScreenContent extends StatelessWidget {
Widget build(BuildContext context) {
final model = context.watch().state.model;
return MwLauncherShell(
- news: _buildNews(model),
+ news: _buildNews(context, model),
syncActions: _buildSyncActions(context, model),
statusBar: _buildStatusBar(model),
account: MwAccountAffordance(
- onSettings: () => _showSettings(context, model),
+ username: model.accountUsername,
onLogout: () =>
context.read().add(HomeScreenLogoutRequested()),
),
+ onSettings: () => _showSettings(context, model),
+ username: model.accountUsername,
onDrag: windowManager.startDragging,
onDoubleTap: () => unawaited(_toggleMaximized()),
onMinimize: windowManager.minimize,
@@ -33,7 +37,7 @@ class HomeScreenContent extends StatelessWidget {
);
}
- Widget _buildNews(HomeScreenModel model) {
+ Widget _buildNews(BuildContext context, HomeScreenModel model) {
final state = model.isLoadingNews
? MwNewsListState.loading
: model.newsItems.isNotEmpty
@@ -51,11 +55,40 @@ class HomeScreenContent extends StatelessWidget {
body: item.body,
createdAt: item.createdAt,
imageUrl: item.imageUrl,
+ onPressed: () => unawaited(_openNews(context, item.id)),
),
],
);
}
+ Future _openNews(BuildContext context, int newsId) async {
+ final newsUri = buildLauncherNewsUri(Config.launcherApiBaseUrl, newsId);
+ if (newsUri == null) {
+ _showLinkError(context);
+ return;
+ }
+
+ try {
+ final opened = await launchUrl(
+ newsUri,
+ mode: LaunchMode.externalApplication,
+ );
+ if (!opened && context.mounted) {
+ _showLinkError(context);
+ }
+ } catch (_) {
+ if (context.mounted) {
+ _showLinkError(context);
+ }
+ }
+ }
+
+ void _showLinkError(BuildContext context) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Не удалось открыть новость в браузере.')),
+ );
+ }
+
Widget _buildSyncActions(BuildContext context, HomeScreenModel model) {
final presentation = _syncPresentation(model);
final progress = model.progress.total > 0
@@ -67,6 +100,9 @@ class HomeScreenContent extends StatelessWidget {
currentPath: model.currentPath,
error: model.errorMessage,
progress: progress,
+ realmName: model.realm?.name,
+ realmOnline: model.realm?.online,
+ realmGameBuild: model.realm?.gameBuild,
onPrimary: () {
final bloc = context.read();
if (model.phase == HomeScreenPhase.readyToPlay && model.canPlay) {
@@ -89,7 +125,7 @@ class HomeScreenContent extends StatelessWidget {
return MwSyncPresentation.failure;
}
if (model.phase == HomeScreenPhase.readyToPlay) {
- return model.statusText.startsWith('Игра запущена')
+ return model.isGameRunning
? MwSyncPresentation.launched
: MwSyncPresentation.ready;
}
@@ -125,35 +161,23 @@ class HomeScreenContent extends StatelessWidget {
HomeScreenModel model,
) async {
final bloc = context.read();
- final themeController = LauncherThemeScope.of(context);
await showDialog(
context: context,
builder: (dialogContext) => Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460, maxHeight: 620),
- child: AnimatedBuilder(
- animation: themeController,
- builder: (context, _) => SingleChildScrollView(
- child: MwSettingsPanel(
- installationPath: model.outputPath?.toFilePath(),
- themeVariant: themeController.variant,
- syncActive: model.isSyncing,
- onChooseDirectory: () {
- Navigator.of(dialogContext).pop();
- bloc.add(HomeScreenOutputDirRequested());
- },
- onThemeChanged: (variant) {
- unawaited(themeController.setVariant(variant));
- },
- onVerify: () {
- Navigator.of(dialogContext).pop();
- bloc.add(HomeScreenSyncRequested());
- },
- onLogout: () {
- Navigator.of(dialogContext).pop();
- bloc.add(HomeScreenLogoutRequested());
- },
- ),
+ child: SingleChildScrollView(
+ child: MwSettingsPanel(
+ installationPath: model.outputPath?.toFilePath(),
+ syncActive: model.isSyncing || model.isGameRunning,
+ onChooseDirectory: () {
+ Navigator.of(dialogContext).pop();
+ bloc.add(HomeScreenOutputDirRequested());
+ },
+ onVerify: () {
+ Navigator.of(dialogContext).pop();
+ bloc.add(HomeScreenSyncRequested());
+ },
),
),
),
diff --git a/lib/app/login_screen/login_screen.dart b/lib/app/login_screen/login_screen.dart
index 3047890..7d23324 100644
--- a/lib/app/login_screen/login_screen.dart
+++ b/lib/app/login_screen/login_screen.dart
@@ -18,17 +18,30 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State {
final _usernameController = TextEditingController();
+ final _emailController = TextEditingController();
final _passwordController = TextEditingController();
+ final _passwordConfirmationController = TextEditingController();
+ final _inviteCodeController = TextEditingController();
+ MwAuthenticationMode _mode = MwAuthenticationMode.login;
+ bool _termsAccepted = false;
bool _isLoading = false;
String? _error;
+ String? _success;
@override
void dispose() {
_usernameController.dispose();
+ _emailController.dispose();
_passwordController.dispose();
+ _passwordConfirmationController.dispose();
+ _inviteCodeController.dispose();
super.dispose();
}
+ Future _submit() {
+ return _mode == MwAuthenticationMode.login ? _login() : _register();
+ }
+
Future _login() async {
final username = _usernameController.text.trim();
final password = _passwordController.text.trim();
@@ -41,6 +54,7 @@ class _LoginScreenState extends State {
setState(() {
_isLoading = true;
_error = null;
+ _success = null;
});
try {
@@ -71,6 +85,89 @@ class _LoginScreenState extends State {
}
}
+ Future _register() async {
+ final username = _usernameController.text.trim();
+ final email = _emailController.text.trim();
+ final password = _passwordController.text;
+ final passwordConfirmation = _passwordConfirmationController.text;
+
+ final validationError = _validateRegistration(
+ username: username,
+ email: email,
+ password: password,
+ passwordConfirmation: passwordConfirmation,
+ );
+ if (validationError != null) {
+ setState(() => _error = validationError);
+ return;
+ }
+
+ setState(() {
+ _isLoading = true;
+ _error = null;
+ _success = null;
+ });
+
+ try {
+ final message = await getIt().register(
+ username: username,
+ email: email,
+ password: password,
+ passwordConfirmation: passwordConfirmation,
+ terms: _termsAccepted,
+ inviteCode: _inviteCodeController.text,
+ );
+
+ if (!mounted) return;
+ setState(() {
+ _mode = MwAuthenticationMode.login;
+ _isLoading = false;
+ _termsAccepted = false;
+ _passwordConfirmationController.clear();
+ _inviteCodeController.clear();
+ _success = message;
+ });
+ } catch (error) {
+ if (!mounted) return;
+ setState(() {
+ _isLoading = false;
+ _error = _formatError(error);
+ });
+ }
+ }
+
+ String? _validateRegistration({
+ required String username,
+ required String email,
+ required String password,
+ required String passwordConfirmation,
+ }) {
+ if (!RegExp(r'^[A-Za-z0-9]{3,32}$').hasMatch(username)) {
+ return 'Логин должен содержать от 3 до 32 латинских букв или цифр.';
+ }
+ if (!RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email)) {
+ return 'Введите корректный email.';
+ }
+ if (password.length < 8 || password.length > 32) {
+ return 'Пароль должен содержать от 8 до 32 символов.';
+ }
+ if (password != passwordConfirmation) {
+ return 'Пароли не совпадают.';
+ }
+ if (!_termsAccepted) {
+ return 'Примите правила сервера и пользовательское соглашение.';
+ }
+ return null;
+ }
+
+ void _setMode(MwAuthenticationMode mode) {
+ setState(() {
+ _mode = mode;
+ _error = null;
+ _success = null;
+ });
+ }
+
String _formatError(Object error) {
final raw = error.toString();
if (raw.startsWith('Exception: ')) return raw.substring(11);
@@ -82,10 +179,18 @@ class _LoginScreenState extends State {
return MwLoginShell(
form: MwAuthenticationForm(
usernameController: _usernameController,
+ emailController: _emailController,
passwordController: _passwordController,
+ passwordConfirmationController: _passwordConfirmationController,
+ inviteCodeController: _inviteCodeController,
+ mode: _mode,
+ onModeChanged: _setMode,
+ termsAccepted: _termsAccepted,
+ onTermsChanged: (value) => setState(() => _termsAccepted = value),
loading: _isLoading,
error: _error,
- onSubmit: _login,
+ success: _success,
+ onSubmit: _submit,
),
onDrag: windowManager.startDragging,
onDoubleTap: () => unawaited(_toggleMaximized()),
diff --git a/lib/app/theme/moonwell_design_system.dart b/lib/app/theme/moonwell_design_system.dart
index e69f910..c8b089f 100644
--- a/lib/app/theme/moonwell_design_system.dart
+++ b/lib/app/theme/moonwell_design_system.dart
@@ -204,6 +204,7 @@ abstract final class MoonWellTheme {
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surfaceHigh,
+ hintStyle: TextStyle(color: foreground.withAlpha(105)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
diff --git a/lib/features/launcher/application/client_sync_use_case.dart b/lib/features/launcher/application/client_sync_use_case.dart
index 8afb0fa..da7fa52 100644
--- a/lib/features/launcher/application/client_sync_use_case.dart
+++ b/lib/features/launcher/application/client_sync_use_case.dart
@@ -149,7 +149,7 @@ class ClientSyncUseCase
ClientSyncStatus(
stage: ClientSyncStage.completed,
progress: _fullProgress(manifest),
- message: 'Client is up to date.',
+ message: 'Клиент обновлён.',
currentPath: null,
localBuildHash: localSnapshot.buildHash,
remoteBuildHash: remoteBuildHash,
@@ -225,7 +225,7 @@ class ClientSyncUseCase
ClientSyncStatus(
stage: ClientSyncStage.completed,
progress: _fullProgress(manifest),
- message: 'Client is ready to play.',
+ message: 'Клиент готов к игре.',
currentPath: null,
localBuildHash: verifiedSnapshot.buildHash,
remoteBuildHash: remoteBuildHash,
diff --git a/lib/features/launcher/data/game_installation_service.dart b/lib/features/launcher/data/game_installation_service.dart
index c2617ea..7d5b560 100644
--- a/lib/features/launcher/data/game_installation_service.dart
+++ b/lib/features/launcher/data/game_installation_service.dart
@@ -22,6 +22,13 @@ typedef InstallationScanProgressCallback =
int totalFiles,
);
+final class GameProcessHandle {
+ final int pid;
+ final Future exitCode;
+
+ const GameProcessHandle({required this.pid, required this.exitCode});
+}
+
@lazySingleton
class GameInstallationService {
Future scanInstallation(
@@ -209,7 +216,7 @@ class GameInstallationService {
await cacheDirectory.create(recursive: true);
}
- Future launchGame(String installationDir) async {
+ Future launchGame(String installationDir) async {
final executablePath = getExecutablePath(installationDir);
final executable = File(executablePath);
@@ -219,12 +226,16 @@ class GameInstallationService {
);
}
- await Process.start(
+ final process = await Process.start(
executablePath,
const [],
workingDirectory: installationDir,
- mode: ProcessStartMode.detached,
+ mode: ProcessStartMode.normal,
);
+ await process.stdin.close();
+ unawaited(process.stdout.drain());
+ unawaited(process.stderr.drain());
+ return GameProcessHandle(pid: process.pid, exitCode: process.exitCode);
}
Future hasClientExecutable(String installationDir) async {
diff --git a/lib/features/launcher/data/launcher_api_client.dart b/lib/features/launcher/data/launcher_api_client.dart
index f7a379d..527fafa 100644
--- a/lib/features/launcher/data/launcher_api_client.dart
+++ b/lib/features/launcher/data/launcher_api_client.dart
@@ -9,8 +9,10 @@ import 'package:moonwell_launcher/features/downloader/domain/entities/download_e
import 'package:moonwell_launcher/features/launcher/data/launcher_log_service.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_account.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
+import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
@lazySingleton
@@ -58,6 +60,108 @@ class LauncherApiClient {
}
}
+ Future register({
+ required String username,
+ required String email,
+ required String password,
+ required String passwordConfirmation,
+ required bool terms,
+ String? inviteCode,
+ }) async {
+ final registerUri = _resolveUri('api/launcher/register');
+ final normalizedInviteCode = inviteCode?.trim();
+
+ try {
+ final response = await _dio.postUri(
+ registerUri,
+ data: {
+ 'username': username,
+ 'email': email,
+ 'password': password,
+ 'password_confirmation': passwordConfirmation,
+ 'terms': terms,
+ if (normalizedInviteCode != null && normalizedInviteCode.isNotEmpty)
+ 'invite_code': normalizedInviteCode,
+ },
+ );
+ final payload = _coerceMap(response.data);
+ return payload['message'] as String? ?? 'Аккаунт успешно создан.';
+ } on DioException catch (error, stackTrace) {
+ await _launcherLogService.error(
+ 'Launcher registration request failed.',
+ details: {
+ 'endpoint': registerUri.toString(),
+ 'statusCode': error.response?.statusCode,
+ 'dioType': error.type.name,
+ 'responseBody': _summarizePayload(error.response?.data),
+ },
+ error: error,
+ stackTrace: stackTrace,
+ );
+ throw LauncherApiException(_extractErrorMessage(error));
+ }
+ }
+
+ Future
> fetchRealms() async {
+ final realmsUri = _resolveUri('api/launcher/realms');
+
+ try {
+ final response = await _dio.getUri(realmsUri);
+ final payload = _coerceMap(response.data);
+ final rawItems = payload['data'];
+ if (rawItems is! List) {
+ throw const LauncherApiException('Unexpected launcher realms payload.');
+ }
+
+ return rawItems
+ .whereType