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() + .map( + (item) => LauncherRealm.fromJson( + item.map((key, value) => MapEntry(key.toString(), value)), + ), + ) + .toList(growable: false); + } on DioException catch (error, stackTrace) { + await _launcherLogService.error( + 'Launcher realms request failed.', + details: { + 'endpoint': realmsUri.toString(), + 'statusCode': error.response?.statusCode, + 'dioType': error.type.name, + 'responseBody': _summarizePayload(error.response?.data), + }, + error: error, + stackTrace: stackTrace, + ); + throw LauncherApiException(_extractErrorMessage(error)); + } + } + + Future fetchAccount(String accessToken) async { + final accountUri = _resolveUri('api/launcher/account'); + + try { + final response = await _dio.getUri( + accountUri, + options: _authorizedOptions(accessToken), + ); + return LauncherAccount.fromJson(_coerceMap(response.data)); + } on DioException catch (error, stackTrace) { + await _launcherLogService.error( + 'Launcher account request failed.', + details: { + 'endpoint': accountUri.toString(), + 'statusCode': error.response?.statusCode, + 'dioType': error.type.name, + 'responseBody': _summarizePayload(error.response?.data), + }, + error: error, + stackTrace: stackTrace, + ); + throw LauncherApiException(_extractErrorMessage(error)); + } + } + Future fetchManifest(String accessToken) async { final manifestUri = _resolveUri('api/launcher/manifest'); diff --git a/lib/features/launcher/data/launcher_news_link.dart b/lib/features/launcher/data/launcher_news_link.dart new file mode 100644 index 0000000..b23e1a7 --- /dev/null +++ b/lib/features/launcher/data/launcher_news_link.dart @@ -0,0 +1,14 @@ +Uri? buildLauncherNewsUri(String rawBaseUrl, int newsId) { + final normalizedBaseUrl = rawBaseUrl.trim(); + final baseUri = Uri.tryParse( + normalizedBaseUrl.endsWith('/') ? normalizedBaseUrl : '$normalizedBaseUrl/', + ); + if (baseUri == null || + !baseUri.hasScheme || + baseUri.host.isEmpty || + newsId <= 0) { + return null; + } + + return baseUri.resolve('news/$newsId'); +} diff --git a/lib/features/launcher/domain/entities/launcher_account.dart b/lib/features/launcher/domain/entities/launcher_account.dart new file mode 100644 index 0000000..8d4542f --- /dev/null +++ b/lib/features/launcher/domain/entities/launcher_account.dart @@ -0,0 +1,11 @@ +final class LauncherAccount { + final String username; + + const LauncherAccount({required this.username}); + + factory LauncherAccount.fromJson(Map json) { + return LauncherAccount( + username: (json['username'] as String? ?? '').trim(), + ); + } +} diff --git a/lib/features/launcher/domain/entities/launcher_realm.dart b/lib/features/launcher/domain/entities/launcher_realm.dart new file mode 100644 index 0000000..6293b00 --- /dev/null +++ b/lib/features/launcher/domain/entities/launcher_realm.dart @@ -0,0 +1,35 @@ +final class LauncherRealm { + final int id; + final String name; + final String address; + final int port; + final double population; + final int gameBuild; + final bool online; + final String status; + + const LauncherRealm({ + required this.id, + required this.name, + required this.address, + required this.port, + required this.population, + required this.gameBuild, + required this.online, + required this.status, + }); + + factory LauncherRealm.fromJson(Map json) { + final online = json['online'] == true; + return LauncherRealm( + id: (json['id'] as num?)?.toInt() ?? 0, + name: json['name'] as String? ?? '', + address: json['address'] as String? ?? '', + port: (json['port'] as num?)?.toInt() ?? 0, + population: (json['population'] as num?)?.toDouble() ?? 0, + gameBuild: (json['game_build'] as num?)?.toInt() ?? 0, + online: online, + status: json['status'] as String? ?? (online ? 'online' : 'offline'), + ); + } +} diff --git a/linux/flutter/ephemeral/.plugin_symlinks/file_picker b/linux/flutter/ephemeral/.plugin_symlinks/file_picker new file mode 120000 index 0000000..3a5e842 --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/file_picker @@ -0,0 +1 @@ +C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/file_picker-11.0.2/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_linux b/linux/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_linux new file mode 120000 index 0000000..c2e3e9d --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_linux @@ -0,0 +1 @@ +C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_secure_storage_linux-3.0.1/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/jni b/linux/flutter/ephemeral/.plugin_symlinks/jni new file mode 120000 index 0000000..a9d1892 --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/jni @@ -0,0 +1 @@ +C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/jni-1.0.0/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux b/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux new file mode 120000 index 0000000..3ff6c73 --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux @@ -0,0 +1 @@ +C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/path_provider_linux-2.2.1/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/screen_retriever_linux b/linux/flutter/ephemeral/.plugin_symlinks/screen_retriever_linux new file mode 120000 index 0000000..16b19dd --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/screen_retriever_linux @@ -0,0 +1 @@ +C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/screen_retriever_linux-0.2.1/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/shared_preferences_linux b/linux/flutter/ephemeral/.plugin_symlinks/shared_preferences_linux new file mode 120000 index 0000000..cec5c3d --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/shared_preferences_linux @@ -0,0 +1 @@ +C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_linux-2.4.1/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux b/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux new file mode 120000 index 0000000..b2aa4b6 --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux @@ -0,0 +1 @@ +C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/url_launcher_linux-3.2.2/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/window_manager b/linux/flutter/ephemeral/.plugin_symlinks/window_manager new file mode 120000 index 0000000..58e2c40 --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/window_manager @@ -0,0 +1 @@ +C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/window_manager-0.5.1/ \ No newline at end of file diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..bc9d47b --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,27 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); + screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..79de9a7 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,28 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux + screen_retriever_linux + url_launcher_linux + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 8be8248..d8e7226 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import file_picker import flutter_secure_storage_darwin import screen_retriever_macos import shared_preferences_foundation +import url_launcher_macos import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -18,5 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/openapi.json b/openapi.json index 73ad565..4187359 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.0", "info": { "title": "Moonwell Launcher API", - "description": "API для лаунчера WoW-клиента Moonwell. Авторизация, получение манифеста файлов и скачивание обновлений.", + "description": "API для лаунчера WoW-клиента Moonwell. Авторизация, новости, реалмы и их статусы, получение манифеста файлов и скачивание обновлений.", "version": "1.0.0" }, "paths": { @@ -274,6 +274,402 @@ } ] } + }, + "/api/service/update-appcast": { + "post": { + "tags": [ + "Launcher Service" + ], + "summary": "Обновить appcast лаунчера", + "description": "Служебный endpoint для разработчика лаунчера. Принимает готовый XML appcast в формате RSS 2.0/Sparkle, который использует Flutter-пакет `upgrader`, и сохраняет его как публичный `/appcast.xml` на сервере. Тело запроса передается как raw XML, без JSON-обертки.", + "operationId": "0b1b8798e0eefc46277891ccc1440afb", + "requestBody": { + "required": true, + "content": { + "application/xml": { + "schema": { + "type": "string", + "example": "\n\n \n Moonwell Launcher - Appcast\n \n Version 1.2.0\n Описание изменений для окна обновления.\n Sat, 16 May 2026 12:00:00 +0400\n \n \n \n" + } + } + } + }, + "responses": { + "200": { + "description": "Appcast сохранен", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "XML saved" + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "Нет доступа или передано пустое тело запроса", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "empty body" + } + }, + "type": "object" + } + } + } + }, + "500": { + "description": "Не удалось сохранить appcast.xml", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Failed to save XML" + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "launcherAuth": [] + } + ] + } + }, + "/api/launcher/news": { + "get": { + "tags": [ + "Launcher" + ], + "summary": "Список новостей лаунчера", + "description": "Возвращает опубликованные новости для отображения в лаунчере. Отсортированы по sort_order и дате создания.", + "operationId": "69aa09e0c7cca466e8a45a186444245e", + "responses": { + "200": { + "description": "Список новостей", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "title": { + "type": "string", + "example": "Обновление 1.2" + }, + "body": { + "type": "string", + "example": "Описание обновления..." + }, + "image_url": { + "type": "string", + "format": "uri", + "example": "https://storage.yandexcloud.net/warcraft-client/news/update-1.2.jpg", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Не авторизован", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Unauthenticated." + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/launcher/realms": { + "get": { + "tags": [ + "Launcher" + ], + "summary": "Получить список реалмов и их статус", + "description": "Возвращает реалмы из таблицы realmlist. Статус online означает, что игровой порт реалма доступен по TCP.", + "operationId": "6d142b37a7b9a121b7ad02fd6405e054", + "responses": { + "200": { + "description": "Список реалмов", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "name": { + "type": "string", + "example": "MoonWell" + }, + "address": { + "type": "string", + "example": "logon.moon-well.online" + }, + "port": { + "type": "integer", + "example": 8085 + }, + "icon": { + "type": "integer", + "example": 1 + }, + "flag": { + "type": "integer", + "example": 0 + }, + "timezone": { + "type": "integer", + "example": 4 + }, + "population": { + "type": "number", + "format": "float", + "example": 0.5 + }, + "game_build": { + "type": "integer", + "example": 12340 + }, + "online": { + "type": "boolean", + "example": true + }, + "status": { + "type": "string", + "example": "online", + "enum": [ + "online", + "offline" + ] + } + }, + "type": "object" + } + }, + "checked_at": { + "type": "string", + "format": "date-time" + } + }, + "type": "object" + } + } + } + }, + "503": { + "description": "Не удалось получить список реалмов", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Не удалось получить список реалмов." + } + }, + "type": "object" + } + } + } + }, + "429": { + "description": "Слишком много запросов" + } + } + } + }, + "/api/launcher/register": { + "post": { + "tags": [ + "Launcher Auth" + ], + "summary": "Зарегистрировать игровой аккаунт", + "description": "Создаёт игровой аккаунт AzerothCore. Инвайт-код обязателен, если на сервере включена регистрация по приглашениям.", + "operationId": "bfc1dbd42cbcc339bc8a13dbf189c525", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "required": [ + "username", + "email", + "password", + "password_confirmation", + "terms" + ], + "properties": { + "username": { + "type": "string", + "pattern": "^[A-Za-z0-9]+$", + "example": "Player", + "maxLength": 32, + "minLength": 3 + }, + "email": { + "type": "string", + "format": "email", + "example": "player@example.com", + "maxLength": 255 + }, + "invite_code": { + "type": "string", + "example": "ABCD-EFGH-IJKL", + "nullable": true, + "maxLength": 32, + "minLength": 6 + }, + "password": { + "type": "string", + "example": "Secret123", + "maxLength": 32, + "minLength": 8 + }, + "password_confirmation": { + "type": "string", + "example": "Secret123", + "maxLength": 32, + "minLength": 8 + }, + "terms": { + "description": "Согласие с правилами сервера и пользовательским соглашением", + "type": "boolean", + "example": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Аккаунт создан", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Игровой аккаунт успешно создан." + }, + "account": { + "properties": { + "id": { + "type": "integer", + "example": 77 + }, + "username": { + "type": "string", + "example": "PLAYER" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "422": { + "description": "Ошибка валидации, занятый логин или неверный инвайт-код", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Такой игровой аккаунт уже существует." + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "type": "object" + } + } + } + }, + "500": { + "description": "Внутренняя ошибка регистрации", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Не удалось создать аккаунт. Попробуй снова позже." + } + }, + "type": "object" + } + } + } + }, + "429": { + "description": "Слишком много запросов" + } + } + } } }, "components": { @@ -283,6 +679,12 @@ "description": "Токен, полученный через POST /api/launcher/login", "bearerFormat": "JWT", "scheme": "bearer" + }, + "launcherAuth": { + "type": "apiKey", + "description": "Сервисный ключ из LAUNCHER_AUTH_KEY для запросов лаунчера к служебным endpoint-ам.", + "name": "Auth", + "in": "header" } } }, @@ -294,6 +696,10 @@ { "name": "Launcher", "description": "Launcher" + }, + { + "name": "Launcher Service", + "description": "Launcher Service" } ] } \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index f41efaa..1157d98 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -141,10 +141,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -540,26 +540,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -905,10 +905,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.7" typed_data: dependency: transitive description: @@ -917,6 +917,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + url: "https://pub.dev" + source: hosted + version: "6.3.30" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 157023c..5c82846 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 1.0.1+2 environment: sdk: ">=3.10.0" @@ -46,6 +46,7 @@ dependencies: shared_preferences: ^2.5.3 window_manager: ^0.5.1 auto_updater: 1.0.0 + url_launcher: ^6.3.2 dev_dependencies: flutter_test: @@ -73,6 +74,8 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - assets/background.png + - assets/launcher_key_art.png + - assets/login_key_art.png - assets/logo.png - assets/fonts/Cinzel-OFL.txt - assets/fonts/CormorantGaramond-OFL.txt diff --git a/test/app/design_system/mw_authentication_test.dart b/test/app/design_system/mw_authentication_test.dart new file mode 100644 index 0000000..c395ca4 --- /dev/null +++ b/test/app/design_system/mw_authentication_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:moonwell_launcher/app/design_system/mw_authentication.dart'; +import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart'; + +void main() { + testWidgets('authentication placeholders use muted text color', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: MoonWellTheme.create(MoonWellThemeVariant.forest), + home: Scaffold( + body: MwAuthenticationForm( + usernameController: TextEditingController(), + passwordController: TextEditingController(), + onSubmit: () {}, + ), + ), + ), + ); + + final fieldContext = tester.element(find.byType(TextField).first); + final hintColor = Theme.of( + fieldContext, + ).inputDecorationTheme.hintStyle?.color; + + expect(hintColor, const Color(0x69ECE4D0)); + }); + + testWidgets('registration mode exposes all API registration fields', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: MoonWellTheme.create(MoonWellThemeVariant.forest), + home: Scaffold( + body: SingleChildScrollView( + child: MwAuthenticationForm( + usernameController: TextEditingController(), + emailController: TextEditingController(), + passwordController: TextEditingController(), + passwordConfirmationController: TextEditingController(), + inviteCodeController: TextEditingController(), + mode: MwAuthenticationMode.registration, + termsAccepted: true, + onSubmit: () {}, + ), + ), + ), + ), + ); + + expect(find.text('СОЗДАТЬ АККАУНТ'), findsOneWidget); + expect(find.text('EMAIL'), findsOneWidget); + expect(find.text('ПОВТОРИТЕ ПАРОЛЬ'), findsOneWidget); + expect(find.text('ИНВАЙТ-КОД (НЕОБЯЗАТЕЛЬНО)'), findsOneWidget); + expect( + find.text('Я принимаю правила сервера и пользовательское соглашение'), + findsOneWidget, + ); + }); + + testWidgets('authentication tabs request mode changes', (tester) async { + MwAuthenticationMode? requestedMode; + await tester.pumpWidget( + MaterialApp( + theme: MoonWellTheme.create(MoonWellThemeVariant.forest), + home: Scaffold( + body: MwAuthenticationForm( + usernameController: TextEditingController(), + passwordController: TextEditingController(), + onModeChanged: (mode) => requestedMode = mode, + onSubmit: () {}, + ), + ), + ), + ); + + await tester.tap(find.text('РЕГИСТРАЦИЯ')); + + expect(requestedMode, MwAuthenticationMode.registration); + }); +} diff --git a/test/app/design_system/mw_launcher_components_test.dart b/test/app/design_system/mw_launcher_components_test.dart new file mode 100644 index 0000000..2618ab1 --- /dev/null +++ b/test/app/design_system/mw_launcher_components_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart'; +import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart'; + +void main() { + testWidgets('game rail shows WoW and MoonWell Karts', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: MoonWellTheme.create(MoonWellThemeVariant.forest), + home: const Scaffold( + body: SizedBox(width: 240, child: MwGameRail(username: 'Sindoring')), + ), + ), + ); + + expect(find.text('MOONWELL · WOW'), findsOneWidget); + expect(find.text('OPEN BETA'), findsOneWidget); + expect(find.text('S'), findsOneWidget); + expect(find.text('MOONWELL KARTS'), findsOneWidget); + expect(find.text('В РАЗРАБОТКЕ'), findsOneWidget); + expect(find.text('SOON'), findsNothing); + expect(find.textContaining('TIDES OF ELUNE'), findsNothing); + expect(find.textContaining('PROJECT VERGE'), findsNothing); + }); + + testWidgets('game rail settings button invokes settings callback', ( + tester, + ) async { + var settingsRequested = false; + await tester.pumpWidget( + MaterialApp( + theme: MoonWellTheme.create(MoonWellThemeVariant.forest), + home: Scaffold( + body: SizedBox( + width: 240, + child: MwGameRail(onSettings: () => settingsRequested = true), + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Настройки')); + + expect(settingsRequested, isTrue); + }); + + testWidgets('sync area displays realm data received from API', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: MoonWellTheme.create(MoonWellThemeVariant.forest), + home: const Scaffold( + body: SizedBox( + width: 1000, + height: 150, + child: MwSyncActionArea( + state: MwSyncPresentation.ready, + status: 'Клиент готов.', + realmName: 'MoonWell', + realmOnline: true, + realmGameBuild: 12340, + onPrimary: _noop, + ), + ), + ), + ), + ); + + expect(find.text('MOONWELL'), findsOneWidget); + expect(find.text('ONLINE · BUILD 12340'), findsOneWidget); + }); + + testWidgets('news item invokes its link callback when pressed', ( + tester, + ) async { + var pressed = false; + await tester.pumpWidget( + MaterialApp( + theme: MoonWellTheme.create(MoonWellThemeVariant.forest), + home: Scaffold( + body: MwNewsItem( + item: MwNewsItemData( + title: 'Update 1.2', + body: 'News body', + onPressed: () => pressed = true, + ), + ), + ), + ), + ); + + await tester.tap(find.text('Update 1.2')); + + expect(pressed, isTrue); + }); + + testWidgets('account affordance displays username without balance', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: MoonWellTheme.create(MoonWellThemeVariant.forest), + home: Scaffold( + body: MwAccountAffordance(username: 'PLAYERONE', onLogout: _noop), + ), + ), + ); + + expect(find.text('PLAYERONE'), findsOneWidget); + expect(find.textContaining('1250'), findsNothing); + expect(find.text('P'), findsOneWidget); + + await tester.tap(find.byType(PopupMenuButton)); + await tester.pumpAndSettle(); + + expect(find.text('Выйти'), findsOneWidget); + expect(find.text('Настройки'), findsNothing); + }); + + testWidgets('settings panel excludes logout action', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: MoonWellTheme.create(MoonWellThemeVariant.forest), + home: Scaffold( + body: MwSettingsPanel( + installationPath: r'C:\Games\MoonWell', + onChooseDirectory: _noop, + onVerify: _noop, + ), + ), + ), + ); + + expect(find.text('Выбрать папку'), findsOneWidget); + expect(find.text('Проверить файлы'), findsOneWidget); + expect(find.text('Выйти'), findsNothing); + }); +} + +void _noop() {} diff --git a/test/app/design_system/mw_shells_test.dart b/test/app/design_system/mw_shells_test.dart new file mode 100644 index 0000000..4cc6c5e --- /dev/null +++ b/test/app/design_system/mw_shells_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:moonwell_launcher/app/design_system/mw_shells.dart'; +import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart'; + +void main() { + testWidgets('brand mark fits its requested width without overflow', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: MoonWellTheme.create(MoonWellThemeVariant.forest), + home: const Scaffold(body: Center(child: MwBrandMark(width: 220))), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('login shell renders the key art asset', (tester) async { + await tester.binding.setSurfaceSize(const Size(1280, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + theme: MoonWellTheme.create(MoonWellThemeVariant.forest), + home: MwLoginShell( + form: const SizedBox(), + onDrag: _noop, + onDoubleTap: _noop, + onMinimize: _noop, + onMaximizeRestore: _noop, + onClose: _noop, + ), + ), + ); + await tester.pumpAndSettle(); + + final image = tester.widget(find.byType(Image)); + expect((image.image as AssetImage).assetName, 'assets/login_key_art.png'); + expect(tester.takeException(), isNull); + }); + + testWidgets('launcher shell renders the main key art asset', (tester) async { + await tester.binding.setSurfaceSize(const Size(1280, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + theme: MoonWellTheme.create(MoonWellThemeVariant.forest), + home: const MwLauncherShell( + news: SizedBox(), + syncActions: SizedBox(), + statusBar: SizedBox(), + account: SizedBox(), + onDrag: _noop, + onDoubleTap: _noop, + onMinimize: _noop, + onMaximizeRestore: _noop, + onClose: _noop, + ), + ), + ); + await tester.pumpAndSettle(); + + final image = tester.widget(find.byType(Image)); + expect( + (image.image as AssetImage).assetName, + 'assets/launcher_key_art.png', + ); + expect(tester.takeException(), isNull); + }); +} + +void _noop() {} diff --git a/test/app/home_screen/bloc/home_screen_bloc_test.dart b/test/app/home_screen/bloc/home_screen_bloc_test.dart index 8d70b9f..b5e9a38 100644 --- a/test/app/home_screen/bloc/home_screen_bloc_test.dart +++ b/test/app/home_screen/bloc/home_screen_bloc_test.dart @@ -1,15 +1,18 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; -import 'package:moonwell_launcher/core/moonwell_theme_variant.dart'; import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart'; +import 'package:moonwell_launcher/core/moonwell_theme_variant.dart'; +import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart'; import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart'; import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart'; import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.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_account.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'; import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart'; @@ -29,6 +32,19 @@ void main() { createdAt: null, ), ], + realms: const [ + LauncherRealm( + id: 1, + name: 'MoonWell', + address: 'logon.moon-well.online', + port: 8085, + population: 0.5, + gameBuild: 12340, + online: true, + status: 'online', + ), + ], + account: const LauncherAccount(username: 'PLAYERONE'), ), preferencesRepository: _FakePreferencesRepository(), session: LauncherSession( @@ -47,12 +63,18 @@ void main() { expect(bloc.state.model.newsErrorMessage, isNull); expect(bloc.state.model.newsItems, hasLength(1)); expect(bloc.state.model.newsItems.first.title, 'Update 1.2'); + expect(bloc.state.model.realm?.name, 'MoonWell'); + expect(bloc.state.model.realm?.online, isTrue); + expect(bloc.state.model.accountUsername, 'PLAYERONE'); await bloc.close(); }); - test('fetches a fresh manifest for every client sync', () async { + test('starts sync on startup when output directory is saved', () async { final syncUseCase = _CapturingClientSyncUseCase(); + final manifest = ClientManifest.fromJson({ + 'files': >[], + }); final bloc = HomeScreenBloc( clientSyncUseCase: syncUseCase, gameInstallationService: _FakeGameInstallationService(), @@ -65,17 +87,99 @@ void main() { tokenType: 'Bearer', expiresAt: null, ), - manifest: ClientManifest.fromJson({ - 'files': >[], - }), + manifest: manifest, ); await pumpEventQueue(times: 20); - bloc.add(HomeScreenSyncRequested()); + + expect(syncUseCase.inputs, hasLength(1)); + expect(syncUseCase.inputs.single.request.manifest, same(manifest)); + + await bloc.close(); + }); + + test('refreshes manifest and syncs when build changes', () async { + final startupManifest = ClientManifest.fromJson({ + 'files': >[], + }); + final updatedManifest = ClientManifest.fromJson({ + 'files': >[ + { + 'path': 'Wow.exe', + 'size': 42, + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + }, + ], + }); + final syncUseCase = _CapturingClientSyncUseCase(); + final apiClient = _FakeLauncherApiClient( + newsItems: const [], + manifest: updatedManifest, + ); + final bloc = HomeScreenBloc( + clientSyncUseCase: syncUseCase, + gameInstallationService: _FakeGameInstallationService(), + launcherApiClient: apiClient, + preferencesRepository: _FakePreferencesRepository( + outputDir: Uri.directory('C:/World of Warcraft'), + ), + session: LauncherSession( + accessToken: 'token', + tokenType: 'Bearer', + expiresAt: null, + ), + manifest: startupManifest, + ); + + await pumpEventQueue(times: 20); + bloc.add(HomeScreenManifestRefreshRequested()); await pumpEventQueue(times: 20); - expect(syncUseCase.lastInput, isNotNull); - expect(syncUseCase.lastInput!.request.manifest, isNull); + expect(apiClient.manifestRequestCount, 1); + expect(syncUseCase.inputs, hasLength(2)); + expect(syncUseCase.inputs.last.request.manifest, same(updatedManifest)); + + await bloc.close(); + }); + + test('returns to ready state after launched game exits', () async { + final manifest = ClientManifest.fromJson({ + 'files': >[], + }); + final gameService = _TrackingGameInstallationService(); + final bloc = HomeScreenBloc( + clientSyncUseCase: _CapturingClientSyncUseCase(), + gameInstallationService: gameService, + launcherApiClient: _FakeLauncherApiClient(newsItems: const []), + preferencesRepository: _FakePreferencesRepository( + outputDir: Uri.directory('C:/World of Warcraft'), + ), + session: LauncherSession( + accessToken: 'token', + tokenType: 'Bearer', + expiresAt: null, + ), + manifest: manifest, + ); + + await pumpEventQueue(times: 20); + bloc.add(HomeScreenPlayRequested()); + await pumpEventQueue(times: 20); + + expect(gameService.launchCount, 1); + expect(bloc.state.model.isGameRunning, isTrue); + expect(bloc.state.model.canPlay, isFalse); + + gameService.exitCode.complete(0); + await pumpEventQueue(times: 20); + + expect(bloc.state.model.isGameRunning, isFalse); + expect(bloc.state.model.canPlay, isTrue); + expect( + bloc.state.model.statusText, + 'Игра закрыта. Клиент готов к запуску.', + ); await bloc.close(); }); @@ -96,23 +200,59 @@ class _IdleClientSyncUseCase extends ClientSyncUseCase { } class _CapturingClientSyncUseCase extends _IdleClientSyncUseCase { - ClientSyncUseCaseInput? lastInput; + final List inputs = []; @override Stream call(ClientSyncUseCaseInput input) { - lastInput = input; - return const Stream.empty(); + inputs.add(input); + final manifest = input.request.manifest; + return Stream.value( + ClientSyncStatus( + stage: ClientSyncStage.completed, + progress: const DownloadProgress.initial(), + message: 'Клиент обновлён.', + currentPath: null, + localBuildHash: manifest?.buildHash, + remoteBuildHash: manifest?.buildHash, + processedFiles: manifest?.files.length ?? 0, + totalFiles: manifest?.files.length ?? 0, + ), + ); } } class _FakeLauncherApiClient extends LauncherApiClient { - _FakeLauncherApiClient({required this.newsItems}); + _FakeLauncherApiClient({ + required this.newsItems, + this.manifest, + this.realms = const [], + this.account = const LauncherAccount(username: ''), + }); final List newsItems; + final ClientManifest? manifest; + final List realms; + final LauncherAccount account; + int manifestRequestCount = 0; @override Future> fetchNews(String accessToken) async => newsItems; + + @override + Future> fetchRealms() async => realms; + + @override + Future fetchAccount(String accessToken) async => account; + + @override + Future fetchManifest(String accessToken) async { + manifestRequestCount += 1; + return manifest ?? + ClientManifest.fromJson({ + 'files': >[], + }); + } } class _FakeGameInstallationService extends GameInstallationService { @@ -129,6 +269,23 @@ class _FakeGameInstallationService extends GameInstallationService { } } +class _TrackingGameInstallationService extends _FakeGameInstallationService { + final Completer exitCode = Completer(); + int launchCount = 0; + + @override + Future hasClientExecutable(String installationDir) async => true; + + @override + Future clearCache(String installationDir) async {} + + @override + Future launchGame(String installationDir) async { + launchCount += 1; + return GameProcessHandle(pid: 42, exitCode: exitCode.future); + } +} + class _FakePreferencesRepository implements PreferencesRepository { _FakePreferencesRepository({this.outputDir}); diff --git a/test/features/launcher/application/client_sync_use_case_test.dart b/test/features/launcher/application/client_sync_use_case_test.dart index 8568578..833c4ec 100644 --- a/test/features/launcher/application/client_sync_use_case_test.dart +++ b/test/features/launcher/application/client_sync_use_case_test.dart @@ -64,7 +64,7 @@ void main() { .toList(); expect(statuses.last.stage, ClientSyncStage.completed); - expect(statuses.last.message, 'Client is up to date.'); + expect(statuses.last.message, 'Клиент обновлён.'); expect(api.downloadedPaths, isEmpty); expect(installationService.deletedRelativeFiles, isEmpty); expect(logger.errorMessages, isEmpty); @@ -139,7 +139,7 @@ void main() { ); expect(installationService.deletedRelativeFiles, ['legacy.txt']); expect(api.downloadedPaths, ['Wow.exe', 'Data/common.MPQ']); - expect(statuses.last.message, 'Client is ready to play.'); + expect(statuses.last.message, 'Клиент готов к игре.'); expect(logger.errorMessages, isEmpty); }, ); diff --git a/test/features/launcher/data/launcher_news_link_test.dart b/test/features/launcher/data/launcher_news_link_test.dart new file mode 100644 index 0000000..2ed20d0 --- /dev/null +++ b/test/features/launcher/data/launcher_news_link_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:moonwell_launcher/features/launcher/data/launcher_news_link.dart'; + +void main() { + test('builds public news URL from launcher base URL and news id', () { + expect( + buildLauncherNewsUri('http://localhost:8080', 4), + Uri.parse('http://localhost:8080/news/4'), + ); + }); + + test('preserves a base URL path when building news URL', () { + expect( + buildLauncherNewsUri('https://example.com/moonwell', 7), + Uri.parse('https://example.com/moonwell/news/7'), + ); + }); + + test('rejects invalid base URLs and news ids', () { + expect(buildLauncherNewsUri('', 4), isNull); + expect(buildLauncherNewsUri('not-a-url', 4), isNull); + expect(buildLauncherNewsUri('https://example.com', 0), isNull); + }); +} diff --git a/test/features/launcher/domain/entities/launcher_account_test.dart b/test/features/launcher/domain/entities/launcher_account_test.dart new file mode 100644 index 0000000..1254070 --- /dev/null +++ b/test/features/launcher/domain/entities/launcher_account_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_account.dart'; + +void main() { + test('LauncherAccount parses username without exposing balance', () { + final account = LauncherAccount.fromJson({ + 'username': 'PLAYERONE', + 'balance': '1250.00', + }); + + expect(account.username, 'PLAYERONE'); + }); +} diff --git a/test/features/launcher/domain/entities/launcher_realm_test.dart b/test/features/launcher/domain/entities/launcher_realm_test.dart new file mode 100644 index 0000000..07bd571 --- /dev/null +++ b/test/features/launcher/domain/entities/launcher_realm_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart'; + +void main() { + test('LauncherRealm parses launcher realms payload', () { + final realm = LauncherRealm.fromJson({ + 'id': 1, + 'name': 'MoonWell', + 'address': 'logon.moon-well.online', + 'port': 8085, + 'population': 0.5, + 'game_build': 12340, + 'online': true, + 'status': 'online', + }); + + expect(realm.id, 1); + expect(realm.name, 'MoonWell'); + expect(realm.address, 'logon.moon-well.online'); + expect(realm.port, 8085); + expect(realm.population, 0.5); + expect(realm.gameBuild, 12340); + expect(realm.online, isTrue); + expect(realm.status, 'online'); + }); +} diff --git a/tool/deploy_launcher.ps1 b/tool/deploy_launcher.ps1 new file mode 100644 index 0000000..7f127f5 --- /dev/null +++ b/tool/deploy_launcher.ps1 @@ -0,0 +1,669 @@ +<# +.SYNOPSIS +Builds, signs, verifies, and deploys MoonWell Launcher to production. + +.DESCRIPTION +Builds the Windows application and Inno Setup installer, verifies the DSA +signing key pair, signs the installer, generates appcast.xml, uploads the +installer to Yandex Object Storage, and publishes AppCast through the MoonWell +service API. Secrets are read only from environment variables. + +.PARAMETER ExpectedVersion +Fails unless the X.Y.Z part of pubspec.yaml matches this value. + +.PARAMETER ReleaseNotes +Text written to the AppCast item description. + +.PARAMETER DryRun +Builds, signs, and verifies local artifacts without changing production. + +.PARAMETER Force +Allows redeploying a version that is not newer than the public AppCast. + +.PARAMETER SkipChecks +Skips pub get, formatting, static analysis, and tests. + +.EXAMPLE +.\tool\deploy_launcher.ps1 -DryRun -ExpectedVersion 1.0.2 ` + -ReleaseNotes "MoonWell Launcher improvements." + +.EXAMPLE +.\tool\deploy_launcher.ps1 -ExpectedVersion 1.0.2 ` + -ReleaseNotes "MoonWell Launcher improvements." +#> +#requires -Version 5.1 + +[CmdletBinding()] +param( + [string]$ExpectedVersion, + [string]$ReleaseNotes = 'MoonWell Launcher update.', + [string]$ApiBaseUrl = 'https://moon-well.online', + [string]$AppcastUrl = 'https://moon-well.online/appcast.xml', + [string]$S3Endpoint, + [string]$S3Bucket, + [string]$S3Key = 'moonwell_launcher_setup.exe', + [string]$PrivateKeyPath = '.moonwell_signing\dsa_priv.pem', + [string]$PublicKeyPath = 'windows\runner\resources\dsa_pub.pem', + [string]$FlutterCommand = 'flutter', + [string]$DartCommand = 'dart', + [string]$InnoSetupCommand, + [string]$OpenSslCommand, + [switch]$SkipChecks, + [switch]$DryRun, + [switch]$Force +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:SparkleNamespace = + 'http://www.andymatuschak.org/xml-namespaces/sparkle' + +function Write-Step { + param([Parameter(Mandatory = $true)][string]$Message) + + Write-Host "`n==> $Message" -ForegroundColor Cyan +} + +function Resolve-RequiredCommand { + param( + [Parameter(Mandatory = $true)][string]$Command, + [Parameter(Mandatory = $true)][string]$Description + ) + + if (Test-Path -LiteralPath $Command -PathType Leaf) { + return (Resolve-Path -LiteralPath $Command).Path + } + + $resolved = Get-Command $Command -ErrorAction SilentlyContinue + if ($null -eq $resolved) { + throw "$Description was not found: $Command" + } + + return $resolved.Source +} + +function Invoke-RequiredCommand { + param( + [Parameter(Mandatory = $true)][string]$Command, + [Parameter(Mandatory = $true)][string[]]$Arguments + ) + + & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $Command" + } +} + +function Get-FileSha256 { + param([Parameter(Mandatory = $true)][string]$Path) + + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +function Get-AppcastVersion { + param([Parameter(Mandatory = $true)][string]$Path) + + [xml]$xml = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 + $enclosure = $xml.rss.channel.item.enclosure + if ($null -eq $enclosure) { + throw "AppCast does not contain channel/item/enclosure: $Path" + } + + return $enclosure.GetAttribute('version', $script:SparkleNamespace) +} + +function Write-Appcast { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$Notes, + [Parameter(Mandatory = $true)][string]$InstallerUrl, + [Parameter(Mandatory = $true)][string]$Signature, + [Parameter(Mandatory = $true)][long]$Length + ) + + $settings = New-Object System.Xml.XmlWriterSettings + $settings.Encoding = New-Object System.Text.UTF8Encoding($false) + $settings.Indent = $true + $settings.IndentChars = ' ' + $settings.NewLineChars = "`n" + $settings.NewLineHandling = [System.Xml.NewLineHandling]::Replace + + $writer = [System.Xml.XmlWriter]::Create($Path, $settings) + try { + $writer.WriteStartDocument() + $writer.WriteStartElement('rss') + $writer.WriteAttributeString('version', '2.0') + $writer.WriteAttributeString( + 'xmlns', + 'sparkle', + 'http://www.w3.org/2000/xmlns/', + $script:SparkleNamespace + ) + $writer.WriteStartElement('channel') + $writer.WriteElementString('title', 'MoonWell Launcher') + $writer.WriteElementString( + 'description', + 'MoonWell Launcher updates' + ) + $writer.WriteElementString('language', 'ru') + $writer.WriteStartElement('item') + $writer.WriteElementString( + 'title', + "MoonWell Launcher $Version" + ) + $writer.WriteElementString('description', $Notes) + $writer.WriteElementString( + 'pubDate', + [DateTimeOffset]::Now.ToString( + 'ddd, dd MMM yyyy HH:mm:ss zzz', + [Globalization.CultureInfo]::InvariantCulture + ) + ) + $writer.WriteStartElement('enclosure') + $writer.WriteAttributeString('url', $InstallerUrl) + $writer.WriteAttributeString( + 'sparkle', + 'version', + $script:SparkleNamespace, + $Version + ) + $writer.WriteAttributeString( + 'sparkle', + 'shortVersionString', + $script:SparkleNamespace, + $Version + ) + $writer.WriteAttributeString( + 'sparkle', + 'os', + $script:SparkleNamespace, + 'windows' + ) + $writer.WriteAttributeString( + 'sparkle', + 'dsaSignature', + $script:SparkleNamespace, + $Signature + ) + $writer.WriteAttributeString('length', $Length.ToString()) + $writer.WriteAttributeString( + 'type', + 'application/octet-stream' + ) + $writer.WriteEndElement() + $writer.WriteEndElement() + $writer.WriteEndElement() + $writer.WriteEndElement() + $writer.WriteEndDocument() + } + finally { + $writer.Dispose() + } +} + +function Assert-SigningKeyPair { + param( + [Parameter(Mandatory = $true)][string]$OpenSsl, + [Parameter(Mandatory = $true)][string]$PrivateKey, + [Parameter(Mandatory = $true)][string]$PublicKey, + [Parameter(Mandatory = $true)][string]$TemporaryPublicKey + ) + + Invoke-RequiredCommand -Command $OpenSsl -Arguments @( + 'dsa', + '-in', + $PrivateKey, + '-pubout', + '-out', + $TemporaryPublicKey + ) + + $expected = ( + Get-Content -LiteralPath $PublicKey -Raw + ) -replace '\s', '' + $actual = ( + Get-Content -LiteralPath $TemporaryPublicKey -Raw + ) -replace '\s', '' + + if ($expected -cne $actual) { + throw 'The private DSA key does not match the launcher public key.' + } +} + +function Assert-InstallerSignature { + param( + [Parameter(Mandatory = $true)][string]$OpenSsl, + [Parameter(Mandatory = $true)][string]$InstallerPath, + [Parameter(Mandatory = $true)][string]$PublicKey, + [Parameter(Mandatory = $true)][string]$Signature, + [Parameter(Mandatory = $true)][string]$TemporaryDirectory + ) + + $signaturePath = Join-Path $TemporaryDirectory 'installer.sig' + $firstDigestPath = Join-Path $TemporaryDirectory 'installer.sha1' + [IO.File]::WriteAllBytes( + $signaturePath, + [Convert]::FromBase64String($Signature) + ) + + $stream = [IO.File]::OpenRead($InstallerPath) + try { + $sha1 = [Security.Cryptography.SHA1]::Create() + try { + [IO.File]::WriteAllBytes( + $firstDigestPath, + $sha1.ComputeHash($stream) + ) + } + finally { + $sha1.Dispose() + } + } + finally { + $stream.Dispose() + } + + Invoke-RequiredCommand -Command $OpenSsl -Arguments @( + 'dgst', + '-sha1', + '-verify', + $PublicKey, + '-signature', + $signaturePath, + $firstDigestPath + ) +} + +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$previousLocation = Get-Location + +try { + Set-Location $repositoryRoot + + if ([string]::IsNullOrWhiteSpace($S3Endpoint)) { + $S3Endpoint = $env:AWS_ENDPOINT + } + if ([string]::IsNullOrWhiteSpace($S3Endpoint)) { + $S3Endpoint = 'https://storage.yandexcloud.net' + } + if ([string]::IsNullOrWhiteSpace($S3Bucket)) { + $S3Bucket = $env:AWS_BUCKET + } + if ([string]::IsNullOrWhiteSpace($S3Bucket)) { + $S3Bucket = 'warcraft-client' + } + + foreach ($url in @($ApiBaseUrl, $AppcastUrl, $S3Endpoint)) { + $uri = $null + if ( + -not [Uri]::TryCreate( + $url, + [UriKind]::Absolute, + [ref]$uri + ) -or + $uri.Scheme -ne 'https' + ) { + throw "Production URL must be absolute HTTPS: $url" + } + } + + $python = $null + if (-not $DryRun) { + foreach ($name in @( + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'LAUNCHER_AUTH_KEY' + )) { + $value = [Environment]::GetEnvironmentVariable($name) + if ([string]::IsNullOrWhiteSpace($value)) { + throw "Required environment variable $name is not set." + } + } + + $python = Resolve-RequiredCommand ` + -Command 'python' ` + -Description 'Python' + Invoke-RequiredCommand -Command $python -Arguments @( + '-c', + 'import boto3' + ) + } + + $versionMatch = [regex]::Match( + (Get-Content -LiteralPath 'pubspec.yaml' -Raw), + '(?m)^version:\s*(\d+\.\d+\.\d+)\+(\d+)\s*$' + ) + if (-not $versionMatch.Success) { + throw 'The pubspec.yaml version must use the X.Y.Z+N format.' + } + + $version = $versionMatch.Groups[1].Value + $buildNumber = $versionMatch.Groups[2].Value + if ( + -not [string]::IsNullOrWhiteSpace($ExpectedVersion) -and + $ExpectedVersion -ne $version + ) { + throw "Expected version $ExpectedVersion, pubspec.yaml contains $version." + } + + $privateKey = (Resolve-Path -LiteralPath $PrivateKeyPath).Path + $publicKey = (Resolve-Path -LiteralPath $PublicKeyPath).Path + $flutter = Resolve-RequiredCommand ` + -Command $FlutterCommand ` + -Description 'Flutter SDK' + $dart = Resolve-RequiredCommand ` + -Command $DartCommand ` + -Description 'Dart SDK' + + if ([string]::IsNullOrWhiteSpace($InnoSetupCommand)) { + $InnoSetupCommand = + 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe' + } + $innoSetup = Resolve-RequiredCommand ` + -Command $InnoSetupCommand ` + -Description 'Inno Setup Compiler' + + if ([string]::IsNullOrWhiteSpace($OpenSslCommand)) { + $gitOpenSsl = 'C:\Program Files\Git\usr\bin\openssl.exe' + $OpenSslCommand = if (Test-Path -LiteralPath $gitOpenSsl) { + $gitOpenSsl + } + else { + 'openssl' + } + } + $openSsl = Resolve-RequiredCommand ` + -Command $OpenSslCommand ` + -Description 'OpenSSL' + + $temporaryDirectory = Join-Path $repositoryRoot 'build\launcher_release' + New-Item -ItemType Directory -Force -Path $temporaryDirectory | Out-Null + + Write-Step "Checking DSA keys for version $version+$buildNumber" + Assert-SigningKeyPair ` + -OpenSsl $openSsl ` + -PrivateKey $privateKey ` + -PublicKey $publicKey ` + -TemporaryPublicKey ( + Join-Path $temporaryDirectory 'derived_dsa_pub.pem' + ) + + if (-not $DryRun -and -not $Force) { + Write-Step 'Checking the published version' + try { + $publishedPath = Join-Path $temporaryDirectory 'current_appcast.xml' + Invoke-WebRequest ` + -Uri $AppcastUrl ` + -UseBasicParsing ` + -OutFile $publishedPath + $publishedVersion = Get-AppcastVersion -Path $publishedPath + if ( + -not [string]::IsNullOrWhiteSpace($publishedVersion) -and + [version]$version -le [version]$publishedVersion + ) { + throw ( + "Version $version is not newer than published version " + + "$publishedVersion. Increase X.Y.Z or use -Force." + ) + } + } + catch { + if ($_.Exception.Message -like 'Version *') { + throw + } + Write-Warning ( + 'Could not determine the currently published version: ' + + $_.Exception.Message + ) + } + } + + if (-not $SkipChecks) { + Write-Step 'Dependencies, formatting, analysis, and tests' + Invoke-RequiredCommand -Command $flutter -Arguments @('pub', 'get') + Invoke-RequiredCommand -Command $dart -Arguments @( + 'format', + '--output=none', + '--set-exit-if-changed', + 'lib', + 'test' + ) + Invoke-RequiredCommand -Command $flutter -Arguments @( + 'analyze', + 'lib', + 'test' + ) + Invoke-RequiredCommand -Command $flutter -Arguments @('test') + } + + Write-Step 'Building the Windows production application' + Invoke-RequiredCommand -Command $flutter -Arguments @( + 'build', + 'windows', + '--release', + "--dart-define=MOONWELL_API_BASE_URL=$ApiBaseUrl", + "--dart-define=MOONWELL_APPCAST_URL=$AppcastUrl" + ) + + Write-Step 'Building the Inno Setup installer' + Invoke-RequiredCommand -Command $innoSetup -Arguments @( + "/DMyAppVersion=$version", + 'installer\moonwell_launcher.iss' + ) + + $installerPath = Join-Path ( + Join-Path $repositoryRoot 'build\installer' + ) "moonwell_launcher_${version}_windows_setup.exe" + if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) { + throw "The installer was not created: $installerPath" + } + + Write-Step 'Signing the installer with DSA' + $gitOpenSslDirectory = Split-Path $openSsl -Parent + $previousPath = $env:PATH + try { + $env:PATH = "$gitOpenSslDirectory;$env:PATH" + $signOutput = & $dart run auto_updater:sign_update ` + $installerPath ` + $privateKey 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Could not sign the installer: $signOutput" + } + } + finally { + $env:PATH = $previousPath + } + + $signatureMatch = [regex]::Match( + ($signOutput -join "`n"), + 'sparkle:dsaSignature="([^"]+)"', + [Text.RegularExpressions.RegexOptions]::Singleline + ) + if (-not $signatureMatch.Success) { + throw 'The signing tool did not return sparkle:dsaSignature.' + } + $signature = $signatureMatch.Groups[1].Value -replace '\s', '' + [void][Convert]::FromBase64String($signature) + + $installer = Get-Item -LiteralPath $installerPath + $installerSha256 = Get-FileSha256 -Path $installerPath + Assert-InstallerSignature ` + -OpenSsl $openSsl ` + -InstallerPath $installerPath ` + -PublicKey $publicKey ` + -Signature $signature ` + -TemporaryDirectory $temporaryDirectory + + $installerUrl = ( + $S3Endpoint.TrimEnd('/') + + '/' + + $S3Bucket + + '/' + + $S3Key + ) + $appcastPath = if ($DryRun) { + Join-Path $temporaryDirectory 'appcast.xml' + } + else { + Join-Path $repositoryRoot 'appcast.xml' + } + Write-Step 'Generating appcast.xml' + Write-Appcast ` + -Path $appcastPath ` + -Version $version ` + -Notes $ReleaseNotes ` + -InstallerUrl $installerUrl ` + -Signature $signature ` + -Length $installer.Length + + [xml]$generatedAppcast = Get-Content ` + -LiteralPath $appcastPath ` + -Raw ` + -Encoding UTF8 + if ((Get-AppcastVersion -Path $appcastPath) -ne $version) { + throw 'The generated AppCast contains the wrong version.' + } + + if ($DryRun) { + Write-Step 'Dry run complete - production was not changed' + Write-Host "Version: $version+$buildNumber" + Write-Host "Installer: $installerPath" + Write-Host "Size: $($installer.Length)" + Write-Host "SHA-256: $installerSha256" + Write-Host "AppCast: $appcastPath" + return + } + + Write-Step "Uploading $S3Bucket/$S3Key" + $env:MOONWELL_RELEASE_FILE = $installerPath + $env:MOONWELL_RELEASE_ENDPOINT = $S3Endpoint + $env:MOONWELL_RELEASE_BUCKET = $S3Bucket + $env:MOONWELL_RELEASE_KEY = $S3Key + $env:MOONWELL_RELEASE_VERSION = $version + $env:MOONWELL_RELEASE_SHA256 = $installerSha256 + $uploadScript = @' +import os +import boto3 +from botocore.config import Config + +path_style = os.getenv("AWS_USE_PATH_STYLE_ENDPOINT", "true").lower() +addressing_style = "path" if path_style in ("1", "true", "yes") else "virtual" +s3 = boto3.client( + "s3", + endpoint_url=os.environ["MOONWELL_RELEASE_ENDPOINT"], + region_name=os.getenv("AWS_DEFAULT_REGION", "ru-central1"), + config=Config(s3={"addressing_style": addressing_style}), +) +s3.upload_file( + os.environ["MOONWELL_RELEASE_FILE"], + os.environ["MOONWELL_RELEASE_BUCKET"], + os.environ["MOONWELL_RELEASE_KEY"], + ExtraArgs={ + "ACL": "public-read", + "ContentType": "application/x-msdownload", + "CacheControl": "no-cache, max-age=0", + "Metadata": { + "launcher-version": os.environ["MOONWELL_RELEASE_VERSION"], + "sha256": os.environ["MOONWELL_RELEASE_SHA256"], + }, + }, +) +response = s3.head_object( + Bucket=os.environ["MOONWELL_RELEASE_BUCKET"], + Key=os.environ["MOONWELL_RELEASE_KEY"], +) +print("S3_VERSION_ID=" + str(response.get("VersionId", ""))) +print("S3_CONTENT_LENGTH=" + str(response["ContentLength"])) +'@ + try { + Invoke-RequiredCommand -Command $python -Arguments @( + '-c', + $uploadScript + ) + } + finally { + Remove-Item Env:MOONWELL_RELEASE_FILE -ErrorAction SilentlyContinue + Remove-Item Env:MOONWELL_RELEASE_ENDPOINT -ErrorAction SilentlyContinue + Remove-Item Env:MOONWELL_RELEASE_BUCKET -ErrorAction SilentlyContinue + Remove-Item Env:MOONWELL_RELEASE_KEY -ErrorAction SilentlyContinue + Remove-Item Env:MOONWELL_RELEASE_VERSION -ErrorAction SilentlyContinue + Remove-Item Env:MOONWELL_RELEASE_SHA256 -ErrorAction SilentlyContinue + } + + Write-Step 'Verifying the uploaded installer' + $downloadedInstaller = Join-Path ` + $temporaryDirectory ` + 'published_installer.exe' + Invoke-WebRequest ` + -Uri $installerUrl ` + -UseBasicParsing ` + -OutFile $downloadedInstaller + $publishedSha256 = Get-FileSha256 -Path $downloadedInstaller + if ($publishedSha256 -ne $installerSha256) { + throw ( + 'The uploaded installer SHA-256 does not match the local file: ' + + "$publishedSha256" + ) + } + + Write-Step 'Publishing AppCast' + $publishResponse = Invoke-WebRequest ` + -Uri "$($ApiBaseUrl.TrimEnd('/'))/api/service/update-appcast" ` + -Method Post ` + -Headers @{ + Auth = $env:LAUNCHER_AUTH_KEY + Accept = 'application/json' + } ` + -ContentType 'application/xml' ` + -Body ([IO.File]::ReadAllBytes($appcastPath)) ` + -UseBasicParsing + if ($publishResponse.StatusCode -lt 200 -or $publishResponse.StatusCode -ge 300) { + throw "AppCast publication returned HTTP $($publishResponse.StatusCode)." + } + + Write-Step 'Verifying the published AppCast' + $publishedAppcastPath = Join-Path ` + $temporaryDirectory ` + 'published_appcast.xml' + $publicResponse = Invoke-WebRequest ` + -Uri $AppcastUrl ` + -UseBasicParsing ` + -OutFile $publishedAppcastPath ` + -PassThru + if ($publicResponse.StatusCode -ne 200) { + throw "The public AppCast returned HTTP $($publicResponse.StatusCode)." + } + if ( + (Get-FileSha256 -Path $publishedAppcastPath) -ne + (Get-FileSha256 -Path $appcastPath) + ) { + throw 'The published AppCast does not match the local file.' + } + + [xml]$publishedAppcast = Get-Content ` + -LiteralPath $publishedAppcastPath ` + -Raw ` + -Encoding UTF8 + $publishedEnclosure = $publishedAppcast.rss.channel.item.enclosure + if ( + $publishedEnclosure.GetAttribute( + 'version', + $script:SparkleNamespace + ) -ne $version -or + [long]$publishedEnclosure.length -ne $installer.Length -or + $publishedEnclosure.url -ne $installerUrl + ) { + throw 'The published AppCast contains incorrect release data.' + } + + Write-Step 'Production deployment complete' + Write-Host "Version: $version+$buildNumber" + Write-Host "Installer: $installerUrl" + Write-Host "SHA-256: $installerSha256" + Write-Host "AppCast: $AppcastUrl" +} +finally { + Set-Location $previousLocation +} diff --git a/widgetbook/lib/stories/launcher_stories.dart b/widgetbook/lib/stories/launcher_stories.dart index 3226c64..b55d53b 100644 --- a/widgetbook/lib/stories/launcher_stories.dart +++ b/widgetbook/lib/stories/launcher_stories.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart'; -import 'package:moonwell_launcher/core/moonwell_theme_variant.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @@ -31,7 +30,11 @@ Widget buildMwGameTileInteractiveUseCase(BuildContext context) { @widgetbook.UseCase(name: 'full', type: MwGameRail, path: '[Launcher]') Widget buildMwGameRailFullUseCase(BuildContext context) { - return const SizedBox(width: 240, height: 560, child: MwGameRail()); + return const SizedBox( + width: 240, + height: 560, + child: MwGameRail(username: 'Aranthel'), + ); } @widgetbook.UseCase(name: 'compact', type: MwGameRail, path: '[Launcher]') @@ -39,7 +42,7 @@ Widget buildMwGameRailCompactUseCase(BuildContext context) { return const SizedBox( width: 72, height: 560, - child: MwGameRail(compact: true), + child: MwGameRail(compact: true, username: 'Aranthel'), ); } @@ -191,10 +194,7 @@ Widget buildMwLauncherStatusBarMetadataUseCase(BuildContext context) { path: '[Launcher]', ) Widget buildMwAccountAffordanceUseCase(BuildContext context) { - return MwAccountAffordance( - onSettings: () => debugPrint('Settings requested'), - onLogout: () => debugPrint('Logout requested'), - ); + return MwAccountAffordance(onLogout: () => debugPrint('Logout requested')); } @widgetbook.UseCase( @@ -203,12 +203,6 @@ Widget buildMwAccountAffordanceUseCase(BuildContext context) { path: '[Launcher]', ) Widget buildMwSettingsPanelInteractiveUseCase(BuildContext context) { - final variant = context.knobs.object.dropdown( - label: 'themeVariant', - initialOption: MoonWellThemeVariant.forest, - options: MoonWellThemeVariant.values, - labelBuilder: (value) => value.name, - ); return SizedBox( width: 420, child: MwSettingsPanel( @@ -216,15 +210,12 @@ Widget buildMwSettingsPanelInteractiveUseCase(BuildContext context) { label: 'installationPath', initialValue: r'C:\Games\MoonWell', ), - themeVariant: variant, syncActive: context.knobs.boolean( label: 'syncActive', initialValue: false, ), onChooseDirectory: () => debugPrint('Directory selection requested'), - onThemeChanged: (value) => debugPrint('Theme changed: ${value.name}'), onVerify: () => debugPrint('Manual verification requested'), - onLogout: () => debugPrint('Logout requested'), ), ); } diff --git a/widgetbook/lib/stories/shell_stories.dart b/widgetbook/lib/stories/shell_stories.dart index 73acc58..ad12b93 100644 --- a/widgetbook/lib/stories/shell_stories.dart +++ b/widgetbook/lib/stories/shell_stories.dart @@ -147,9 +147,11 @@ MwLauncherShell _launcherShell({ buildHash: 'a93fd1c2', ), account: MwAccountAffordance( - onSettings: () => debugPrint('Settings requested'), + username: 'Aranthel', onLogout: () => debugPrint('Logout requested'), ), + onSettings: () => debugPrint('Settings requested'), + username: 'Aranthel', onDrag: _windowCallback('drag'), onDoubleTap: _windowCallback('double_tap'), onMinimize: _windowCallback('minimize'), diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index aedb01e..4e09c01 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 8987186..6f52a72 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST auto_updater_windows flutter_secure_storage_windows screen_retriever_windows + url_launcher_windows window_manager ) diff --git a/windows/runner/resources/dsa_pub.pem b/windows/runner/resources/dsa_pub.pem index 32dd7cf..71a43d5 100644 --- a/windows/runner/resources/dsa_pub.pem +++ b/windows/runner/resources/dsa_pub.pem @@ -1,20 +1,28 @@ -----BEGIN PUBLIC KEY----- -MIIDQjCCAjUGByqGSM44BAEwggIoAoIBAQC5rT00uqOtUBNAtY/Kzep4xFXX34Xh -ArYMNMsc0lSCm7WzwklN1GTqKwp18gUxU1WX4YepePBwb4Mtm22VdYW76xKUMXpP -fZ/d2e+l2jwo/urixdQ9vdGrYTkPJLS9Mn6q/7/XQnIx/MTroOcYNh1QJj3Gynxy -y2C95Ty4yaxpjz9BP4TzT8XSvAy4ItdE0Xv2DqvruAP8VLR7HLAYMZFxvCuC65EC -3nLIRByL8s7CGq8w2Wn7x8WKI/PQ/RvngMoc0xrK58qV+I2hFefXSYG4T7/aDvOb -MTnIHlBZcgSEGCFDhtRqStKcBVW+ybk9SRpePU8rWL6QTtZTaBRlGlyRAh0A9A66 -ZMG2sKJqs2w6X+dUCXzeNUMSrYpQit6UjwKCAQBJShETJlN90ZvrtYSOUnTrXKlH -rka5s6d1xFUNyqVNlI84oRC2gfw+eVAzbjtwFXv342uSHgC+kwQwK5UClxBTDxcm -YK4yC3288a4BoCf76SkLcUOdEp2/Ef1MjMSWKorcX9SAKY98RihrRXoHLrmDN3vk -/SVqgLv1Auu5X7yHMO2IDWvI29Sh6W13i2joXFt2O5ufsMY1k6sQtnT6R7sLphVw -ifE6ulDwRNjSkPMFCrKyKpnqXmC1TvtAdkSd0DsWEfQuqp3878PZoVTJzHxrl1Y0 -3M7yrbVj5OojdGA9kX0zelrNCIPuH+2vmfeFis8y5XFFEOxc9yWezFYJ3TfEA4IB -BQACggEAfoxCOU4ofKti7T7QVV3d5Oh6fIi2eTLG4utP6Fvtb6AktF6OPJWeDmDe -g3ffLkKyx8/uA+KuZJO3/t/pHvzUvWNmc487bFZYd0dhlObQEu6Qg5uqDVWG4nqy -K1zNmhVO97SqzBjUklyaxQre65w3XI4a1XSk8RAkcMzu5rze4P9BodEChaG/kEih -qCJZivBjhMjaKi5cmE/S9G0zMrdTf152V8WaN0EjbFThC1izxk2j+76t+ALAe1Tt -I1bY9RT8pzjdfRwWHja0cooPILCWL82pcolOAvrNzhkbR9sIcw5t9F517EQch4VD -0W9SMpZl5uKcw3RS0oiFWyyKNWavvQ== +MIIExDCCAzYGByqGSM44BAEwggMpAoIBgQDpng6PomcvdTHaLxSnJJ7I4uHrrr6p +yjUrf3FkBOz9833z8KqD/xnFRHOuuB/LxVHFfAexCT/QlvA0Fbdh5A+zhM2mubdo +d83YlaPdJdq9imhUhqNOOIssY7yD04vHU5O2yHkX9sMIk0RWI/EPlua5xPKcGr5p +yqNEIH32fOjSQ5APKMEY8oRNePEfJAqklNr4AKg35pitaeJWfmmMDUxL0ElgMyTB +VBQOtNv9Q312lUSzVKaud6aN96/Brkm4pxiZfOvPtk0Ch3TDNyllmyHGpiJ3tFuC +vX1fNDjk4NNAlEIriZvkE1h2L8I7YyAv8DzpxGya/s0/okmMh1bylnyA2ekA2Spx +dVlvHBdRdC5cBaujwANGaY1IjT1lBpz41bpilKaXzp9JWKHKmNBYaWhNbZXjpzaA +ZPvhQaQ7qrSWn4F9lUHa00t3sGJxCv/BjCs9uclMsUuNuKck/NUM1LpHjWLbRzUD +RLlpALEKMjOf0blDf9Pm6hiteLFDnXxbzaMCHQDP4E8TpSCJnn3R0RDBdwpKwt2T +VlCPnlTHUjuJAoIBgQC6iQTIGQH/1+LT+QjCOIUuImRC8jXLbvJ9opO3GXNOelWz +LB3rcaFUad1kNoAVbN2ZV3p7lDxnjXNRzxMiuXthGyzj5ckvZ4qval2OevxyQSZf +k9Ux9ETW9dn4uBLQ2Z7oHR2hioB/xy+xVG8WlNsHxQl3duiMZodrLGBDp9xCEUgK +3X6A9fN49PYDu3FRwutYswLOa1XodWacpfebGKWEAB3/ftCA7v+y30nsguYyNfo1 +cAlhH/m62oljUe3asQbjiLgQLqRgUagERrQqp30F+xCRsJUp6ByYQ1zKrs8EXzZN +EZ7pu4c4c29ynrHpPbAnam/Dnppyjay/J70rQ0nEpyULtyfNp3yIni2AvQhi57Bu +QukE1sa+4gUciPBAkyBzI9wc6Qk3VJiD0mJ7xzMoxJjKBLSVxxpYTvnh+H1x4YFk +fgoePjcz79nrw8n8DAtsM0UbTH5u0WFCSbXx6X/0uj1V7fSS+9n0DfzWQ9xaovrH +r5V9xhv9Fup4WzC93RQDggGGAAKCAYEA0VaaXkE8/MCGF6oVBOmHZ0kiPtu51wIZ +Z/0MBR4FH9+X4aatTHcfY5W8b2PRObWSLbwviRHpaOcz0KxOFA4030ZoQcV7h+RC +rPnSgrgKSDK3myvtWGpZn4chIWd0FnGdpCFDdLDkukyqa8B94C/tjlPT2MyY8PiS +wYKAzMEU4TqSkHrOXlNtSo398eH9zOzMXqHe4UWCtO4ZL6b7SaDwR4FaHStp/VRK +bTYkiVPnVF25Ye+txpasJ1DdFXXnQjng0pjlA1CF7ieNmjqPXaKeCu7d+e2Bz3U1 +VPOzmCYfqynAJ5IJEtsnfpPeKlLHtGtNlGctZXSotkpgY4ywWldJA9UtO1lh7gBy +3wykAdiao/O/uQh4cxMes2TqgBCJoBr41OCiir1NiS04m0cJpyVBBAAylvGFvlaU +cWbkSan2aRSarX68nmOwJx4jQYC0QQCe8J3wsEhYY3HgnczYMkmRP0QDj3xVMjRS +nnS8mysVTmqabcs8SjfThrWTpzCbBIgJ -----END PUBLIC KEY-----