правки по лаунчеру

This commit is contained in:
2026-07-28 20:43:53 +04:00
parent 155922a3f1
commit 0b6f7fc647
51 changed files with 3158 additions and 437 deletions
+35 -16
View File
@@ -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
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>MoonWell Launcher</title>
<description>Обновления MoonWell Launcher</description>
<language>ru</language>
<item>
<title>MoonWell Launcher 1.0.1</title>
<description>Обновление интерфейса, синхронизации клиента и интеграции с сервером MoonWell.</description>
<pubDate>Tue, 28 Jul 2026 20:33:00 +0400</pubDate>
<enclosure
url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe"
sparkle:version="1.0.1"
sparkle:shortVersionString="1.0.1"
sparkle:os="windows"
sparkle:dsaSignature="MD4CHQCdoCW5jNymVlRblQpqy8C+jw4W0rVFQX0Bk25PAh0Av2ZcFYwZFQBQlAstQXna/PsuMNWIzXF4jpDuSA=="
length="19989772"
type="application/octet-stream" />
</item>
</channel>
</rss>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+27 -9
View File
@@ -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:
<language>ru</language>
<item>
<title>MoonWell Launcher 1.1.0</title>
<sparkle:releaseNotesLink>https://host/launcher/updates/1.1.0/release-notes.html</sparkle:releaseNotesLink>
<pubDate>Sun, 21 Jun 2026 12:00:00 +0400</pubDate>
<enclosure
url="https://host/launcher/updates/1.1.0/moonwell_launcher_1.1.0_windows_setup.exe"
url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe"
sparkle:version="1.1.0"
sparkle:os="windows"
sparkle:dsaSignature="SIGNATURE_FROM_SIGN_UPDATE"
@@ -90,14 +89,20 @@ to an authenticated endpoint.
Perform Windows signing on the Windows release machine because the package
invokes WinSparkle's Windows signing utility.
The recommended production workflow is the automated
[`tool/deploy_launcher.ps1`](../tool/deploy_launcher.ps1) script. See
[`production_deploy.md`](production_deploy.md) for prerequisites, environment
variables, dry-run usage, deployment, and failure recovery. The steps below are
the manual fallback and describe the same release order.
1. Update `pubspec.yaml` to the new `X.Y.Z+N` version.
2. Run `fvm flutter pub get`, formatting, analysis, and all tests.
2. Run `flutter pub get`, formatting, analysis, and all tests.
3. Build Windows with both production `--dart-define` values.
4. Compile the installer with `ISCC.exe /DMyAppVersion=X.Y.Z`.
5. Make the private key available outside the repository and sign the artifact:
```powershell
fvm dart run auto_updater:sign_update `
dart run auto_updater:sign_update `
build\installer\moonwell_launcher_X.Y.Z_windows_setup.exe `
C:\secure-path\dsa_priv.pem
```
@@ -109,10 +114,23 @@ invokes WinSparkle's Windows signing utility.
(Get-Item build\installer\moonwell_launcher_X.Y.Z_windows_setup.exe).Length
```
7. Upload the versioned installer and release notes first.
7. Upload the installer to the root of the `warcraft-client` bucket as
`moonwell_launcher_setup.exe`. The object must be publicly readable.
8. Download the hosted installer, verify its byte length, and test its DSA
signature before changing the feed.
9. Publish `appcast.xml` last so clients never observe an incomplete release.
9. Set `LAUNCHER_AUTH_KEY` in the release environment and publish
`appcast.xml` last:
```powershell
curl.exe `
-X POST "https://moon-well.online/api/service/update-appcast" `
-H "Auth: $env:LAUNCHER_AUTH_KEY" `
-H "Content-Type: application/xml" `
--data-binary "@appcast.xml"
```
10. Fetch `https://moon-well.online/appcast.xml` and verify the published
version, installer URL, byte length, and DSA signature.
Keep the preceding installer available for rollback and support. Rollback is a
new release with a product version higher than the faulty release; WinSparkle
+50 -5
View File
@@ -21,17 +21,30 @@ The launcher API base URL is provided via:
Current integration mode is compile-time:
- `fvm flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://host`
- `fvm flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host`
- `flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://host`
- `flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host`
## API Flow
The launcher uses these endpoints from `openapi.json`:
1. `POST /api/launcher/login`
2. `GET /api/launcher/manifest`
3. `GET /api/launcher/download/{path}`
4. `GET /api/launcher/news`
2. `POST /api/launcher/register`
3. `GET /api/launcher/manifest`
4. `GET /api/launcher/download/{path}`
5. `GET /api/launcher/news`
6. `GET /api/launcher/realms`
7. `GET /api/launcher/account`
Registration sequence:
1. User opens the registration tab and provides `username`, `email`,
`password`, `password_confirmation`, optional `invite_code`, and accepts
`terms`.
2. Launcher sends the payload to `POST /api/launcher/register`.
3. After a successful `201` response, the form returns to the login tab and
displays the API success message.
4. Validation and invite errors returned by the API are shown in the form.
Authentication sequence:
@@ -41,6 +54,11 @@ Authentication sequence:
4. Successful login persists `LauncherSession` locally.
5. On next launcher start, saved session is reused to fetch manifest again.
6. `LauncherSession` and `ClientManifest` are passed into the home screen.
7. If an installation directory is saved, synchronization starts
automatically with the fetched manifest.
8. While the launcher is open, it refreshes the manifest every five minutes.
9. A server build hash that differs from the verified local build hash starts
synchronization automatically.
Logout sequence:
@@ -65,6 +83,28 @@ News sequence:
2. The response payload is read from the top-level `data` array.
3. Each news item provides `id`, `title`, `body`, optional `image_url`, and `created_at`.
4. News failures do not block patching or play flow; the launcher shows a local error state only inside the news panel.
5. Pressing a news item opens `<MOONWELL_API_BASE_URL>/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 `<install-root>/Wow.exe`
2. launcher clears `<install-root>/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
+152
View File
@@ -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.
+169 -56
View File
@@ -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<MwAuthenticationMode>? onModeChanged;
final bool termsAccepted;
final ValueChanged<bool>? onTermsChanged;
final bool loading;
final String? error;
final String? success;
@override
State<MwAuthenticationForm> createState() => _MwAuthenticationFormState();
@@ -28,6 +46,7 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
@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<MwAuthenticationForm> {
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<MwAuthenticationForm> {
),
),
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<MwAuthenticationForm> {
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<MwAuthenticationForm> {
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<MwAuthenticationForm> {
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<MwAuthenticationForm> {
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<MwAuthenticationForm> {
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<MwAuthenticationForm> {
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,
),
),
),
),
+180 -178
View File
@@ -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<String>(
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<String>(
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<MoonWellThemeVariant> 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<MoonWellThemeVariant>(
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,
),
],
),
);
+165 -73
View File
@@ -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,
+172 -2
View File
@@ -26,11 +26,14 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
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<HomeScreenEvent, HomeScreenState> {
) {
on<HomeScreenLoad>(_onHomeScreenLoad);
on<HomeScreenSyncRequested>(_onHomeScreenSyncRequested);
on<HomeScreenManifestRefreshRequested>(
_onHomeScreenManifestRefreshRequested,
);
on<HomeScreenRealmRefreshRequested>(_onHomeScreenRealmRefreshRequested);
on<HomeScreenAccountLoadRequested>(_onHomeScreenAccountLoadRequested);
on<HomeScreenOutputDirRequested>(_onHomeScreenOutputDirRequested);
on<HomeScreenPauseRequested>(_onHomeScreenPauseRequested);
on<HomeScreenPlayRequested>(_onHomeScreenPlayRequested);
on<HomeScreenGameExited>(_onHomeScreenGameExited);
on<HomeScreenLogoutRequested>(_onHomeScreenLogoutRequested);
on<HomeScreenSyncStatusChanged>(_onHomeScreenSyncStatusChanged);
on<HomeScreenSyncFailed>(_onHomeScreenSyncFailed);
add(HomeScreenLoad());
_manifestRefreshTimer = Timer.periodic(
manifestRefreshInterval,
(_) => add(HomeScreenManifestRefreshRequested()),
);
_realmRefreshTimer = Timer.periodic(
realmRefreshInterval,
(_) => add(HomeScreenRealmRefreshRequested()),
);
}
final ClientSyncUseCase _clientSyncUseCase;
@@ -58,6 +75,10 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
StreamSubscription<ClientSyncStatus>? _syncSubscription;
final LauncherSession _session;
final ClientManifest _startupManifest;
Timer? _manifestRefreshTimer;
Timer? _realmRefreshTimer;
int? _activeGamePid;
bool _pauseRequested = false;
Future<void> _onHomeScreenLoad(
@@ -82,6 +103,12 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
),
);
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<HomeScreenEvent, HomeScreenState> {
}
}
Future<void> _onHomeScreenAccountLoadRequested(
HomeScreenAccountLoadRequested event,
Emitter<HomeScreenState> emit,
) async {
if (!state.model.isAuthenticated) {
return;
}
try {
final account = await _launcherApiClient.fetchAccount(
_session.accessToken,
);
if (!state.model.isAuthenticated) {
return;
}
emit(
HomeScreenState(
model: state.model.copyWith(
accountUsername: account.username.isEmpty ? null : account.username,
),
),
);
} catch (_) {
// Account metadata does not block patching or launching the game.
}
}
Future<void> _onHomeScreenRealmRefreshRequested(
HomeScreenRealmRefreshRequested event,
Emitter<HomeScreenState> emit,
) async {
if (!state.model.isAuthenticated) {
return;
}
try {
final realms = await _launcherApiClient.fetchRealms();
if (!state.model.isAuthenticated) {
return;
}
emit(
HomeScreenState(
model: state.model.copyWith(
realm: realms.isEmpty ? null : realms.first,
),
),
);
} catch (_) {
// Keep the last known realm state and retry on the next timer tick.
}
}
Future<void> _onHomeScreenManifestRefreshRequested(
HomeScreenManifestRefreshRequested event,
Emitter<HomeScreenState> emit,
) async {
if (!state.model.isAuthenticated ||
state.model.isSyncing ||
state.model.isGameRunning ||
state.model.phase == HomeScreenPhase.paused ||
state.model.outputPath == null) {
return;
}
try {
final manifest = await _launcherApiClient.fetchManifest(
_session.accessToken,
);
if (state.model.isSyncing || !state.model.isAuthenticated) {
return;
}
if (manifest.buildHash == state.model.localBuildHash) {
if (manifest.buildHash != state.model.remoteBuildHash) {
emit(
HomeScreenState(
model: state.model.copyWith(remoteBuildHash: manifest.buildHash),
),
);
}
return;
}
add(HomeScreenSyncRequested(manifest: manifest));
} catch (_) {
// Retry on the next timer tick without replacing the stable UI state.
}
}
Future<void> _onHomeScreenSyncRequested(
HomeScreenSyncRequested event,
Emitter<HomeScreenState> emit,
) async {
if (state.model.isSyncing) {
if (state.model.isSyncing || state.model.isGameRunning) {
return;
}
@@ -145,6 +262,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
request: ClientSyncRequest(
installationDir: outputPath.toFilePath(),
accessToken: _session.accessToken,
manifest: event.manifest,
),
isCancelled: () async => _pauseRequested,
),
@@ -228,12 +346,30 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
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<void>(
(exitCode) {
if (!isClosed) {
add(HomeScreenGameExited(pid: process.pid, exitCode: exitCode));
}
},
onError: (_) {
if (!isClosed) {
add(HomeScreenGameExited(pid: process.pid, exitCode: null));
}
},
),
);
emit(
HomeScreenState(
model: state.model.copyWith(
phase: HomeScreenPhase.readyToPlay,
isGameRunning: true,
statusText: 'Игра запущена. Папка Cache очищена перед стартом.',
errorMessage: null,
),
@@ -249,11 +385,39 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
}
}
Future<void> _onHomeScreenGameExited(
HomeScreenGameExited event,
Emitter<HomeScreenState> emit,
) async {
if (_activeGamePid != event.pid) {
return;
}
_activeGamePid = null;
final hasClientExecutable = await _resolveExecutablePresence(
state.model.outputPath,
);
emit(
HomeScreenState(
model: state.model.copyWith(
phase: _stablePhase(hasClientExecutable),
hasClientExecutable: hasClientExecutable,
isGameRunning: false,
statusText: hasClientExecutable
? 'Игра закрыта. Клиент готов к запуску.'
: 'Игра закрыта, но Wow.exe больше не найден.',
errorMessage: null,
),
),
);
}
Future<void> _onHomeScreenLogoutRequested(
HomeScreenLogoutRequested event,
Emitter<HomeScreenState> emit,
) async {
_pauseRequested = true;
_activeGamePid = null;
await _syncSubscription?.cancel();
_syncSubscription = null;
await _preferencesRepository.clearLauncherSession();
@@ -263,6 +427,8 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
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<HomeScreenEvent, HomeScreenState> {
newsItems: const [],
isLoadingNews: false,
newsErrorMessage: null,
realm: null,
),
),
);
@@ -424,6 +591,9 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
@override
Future<void> close() async {
_pauseRequested = true;
_activeGamePid = null;
_manifestRefreshTimer?.cancel();
_realmRefreshTimer?.cancel();
await _syncSubscription?.cancel();
return super.close();
}
@@ -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 {
@@ -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<LauncherNewsItem> 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 <LauncherNewsItem>[],
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<LauncherNewsItem>? 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?,
+53 -29
View File
@@ -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<HomeScreenBloc>().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<HomeScreenBloc>().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<void> _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<HomeScreenBloc>();
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<HomeScreenBloc>();
final themeController = LauncherThemeScope.of(context);
await showDialog<void>(
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());
},
),
),
),
+106 -1
View File
@@ -18,17 +18,30 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State<LoginScreen> {
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<void> _submit() {
return _mode == MwAuthenticationMode.login ? _login() : _register();
}
Future<void> _login() async {
final username = _usernameController.text.trim();
final password = _passwordController.text.trim();
@@ -41,6 +54,7 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() {
_isLoading = true;
_error = null;
_success = null;
});
try {
@@ -71,6 +85,89 @@ class _LoginScreenState extends State<LoginScreen> {
}
}
Future<void> _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<LauncherApiClient>().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<LoginScreen> {
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()),
@@ -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,
@@ -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,
@@ -22,6 +22,13 @@ typedef InstallationScanProgressCallback =
int totalFiles,
);
final class GameProcessHandle {
final int pid;
final Future<int> exitCode;
const GameProcessHandle({required this.pid, required this.exitCode});
}
@lazySingleton
class GameInstallationService {
Future<ClientInstallationSnapshot> scanInstallation(
@@ -209,7 +216,7 @@ class GameInstallationService {
await cacheDirectory.create(recursive: true);
}
Future<void> launchGame(String installationDir) async {
Future<GameProcessHandle> 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 <String>[],
workingDirectory: installationDir,
mode: ProcessStartMode.detached,
mode: ProcessStartMode.normal,
);
await process.stdin.close();
unawaited(process.stdout.drain<void>());
unawaited(process.stderr.drain<void>());
return GameProcessHandle(pid: process.pid, exitCode: process.exitCode);
}
Future<bool> hasClientExecutable(String installationDir) async {
@@ -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<String> 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: <String, Object?>{
'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: <String, Object?>{
'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<List<LauncherRealm>> 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>()
.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: <String, Object?>{
'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<LauncherAccount> 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: <String, Object?>{
'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<ClientManifest> fetchManifest(String accessToken) async {
final manifestUri = _resolveUri('api/launcher/manifest');
@@ -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');
}
@@ -0,0 +1,11 @@
final class LauncherAccount {
final String username;
const LauncherAccount({required this.username});
factory LauncherAccount.fromJson(Map<String, dynamic> json) {
return LauncherAccount(
username: (json['username'] as String? ?? '').trim(),
);
}
}
@@ -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<String, dynamic> 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'),
);
}
}
+1
View File
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/file_picker-11.0.2/
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_secure_storage_linux-3.0.1/
+1
View File
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/jni-1.0.0/
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/path_provider_linux-2.2.1/
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/screen_retriever_linux-0.2.1/
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_linux-2.4.1/
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/url_launcher_linux-3.2.2/
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/window_manager-0.5.1/
@@ -0,0 +1,27 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <window_manager/window_manager_plugin.h>
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);
}
@@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_
+28
View File
@@ -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 $<TARGET_FILE:${plugin}_plugin>)
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)
@@ -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"))
}
+407 -1
View File
@@ -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": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rss version=\"2.0\" xmlns:sparkle=\"http://www.andymatuschak.org/xml-namespaces/sparkle\">\n <channel>\n <title>Moonwell Launcher - Appcast</title>\n <item>\n <title>Version 1.2.0</title>\n <description>Описание изменений для окна обновления.</description>\n <pubDate>Sat, 16 May 2026 12:00:00 +0400</pubDate>\n <enclosure url=\"https://moonwell.su/launcher/download\" sparkle:version=\"1.2.0\" sparkle:os=\"windows\" />\n </item>\n </channel>\n</rss>"
}
}
}
},
"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"
}
]
}
+74 -10
View File
@@ -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:
+4 -1
View File
@@ -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
@@ -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);
});
}
@@ -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<String>));
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() {}
@@ -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<Image>(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<Image>(find.byType(Image));
expect(
(image.image as AssetImage).assetName,
'assets/launcher_key_art.png',
);
expect(tester.takeException(), isNull);
});
}
void _noop() {}
@@ -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(<String, Object?>{
'files': <Map<String, Object?>>[],
});
final bloc = HomeScreenBloc(
clientSyncUseCase: syncUseCase,
gameInstallationService: _FakeGameInstallationService(),
@@ -65,17 +87,99 @@ void main() {
tokenType: 'Bearer',
expiresAt: null,
),
manifest: ClientManifest.fromJson(<String, Object?>{
'files': <Map<String, Object?>>[],
}),
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(<String, Object?>{
'files': <Map<String, Object?>>[],
});
final updatedManifest = ClientManifest.fromJson(<String, Object?>{
'files': <Map<String, Object?>>[
<String, Object?>{
'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(<String, Object?>{
'files': <Map<String, Object?>>[],
});
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<ClientSyncUseCaseInput> inputs = [];
@override
Stream<ClientSyncStatus> call(ClientSyncUseCaseInput input) {
lastInput = input;
return const Stream<ClientSyncStatus>.empty();
inputs.add(input);
final manifest = input.request.manifest;
return Stream<ClientSyncStatus>.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<LauncherNewsItem> newsItems;
final ClientManifest? manifest;
final List<LauncherRealm> realms;
final LauncherAccount account;
int manifestRequestCount = 0;
@override
Future<List<LauncherNewsItem>> fetchNews(String accessToken) async =>
newsItems;
@override
Future<List<LauncherRealm>> fetchRealms() async => realms;
@override
Future<LauncherAccount> fetchAccount(String accessToken) async => account;
@override
Future<ClientManifest> fetchManifest(String accessToken) async {
manifestRequestCount += 1;
return manifest ??
ClientManifest.fromJson(<String, Object?>{
'files': <Map<String, Object?>>[],
});
}
}
class _FakeGameInstallationService extends GameInstallationService {
@@ -129,6 +269,23 @@ class _FakeGameInstallationService extends GameInstallationService {
}
}
class _TrackingGameInstallationService extends _FakeGameInstallationService {
final Completer<int> exitCode = Completer<int>();
int launchCount = 0;
@override
Future<bool> hasClientExecutable(String installationDir) async => true;
@override
Future<void> clearCache(String installationDir) async {}
@override
Future<GameProcessHandle> launchGame(String installationDir) async {
launchCount += 1;
return GameProcessHandle(pid: 42, exitCode: exitCode.future);
}
}
class _FakePreferencesRepository implements PreferencesRepository {
_FakePreferencesRepository({this.outputDir});
@@ -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);
},
);
@@ -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);
});
}
@@ -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(<String, Object?>{
'username': 'PLAYERONE',
'balance': '1250.00',
});
expect(account.username, 'PLAYERONE');
});
}
@@ -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(<String, Object?>{
'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');
});
}
+669
View File
@@ -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
}
+7 -16
View File
@@ -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<MoonWellThemeVariant>(
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'),
),
);
}
+3 -1
View File
@@ -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'),
@@ -9,6 +9,7 @@
#include <auto_updater_windows/auto_updater_windows_plugin_c_api.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <window_manager/window_manager_plugin.h>
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"));
}
+1
View File
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
auto_updater_windows
flutter_secure_storage_windows
screen_retriever_windows
url_launcher_windows
window_manager
)
+26 -18
View File
@@ -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-----