правки по лаунчеру
This commit is contained in:
@@ -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
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
@@ -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_
|
||||
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
auto_updater_windows
|
||||
flutter_secure_storage_windows
|
||||
screen_retriever_windows
|
||||
url_launcher_windows
|
||||
window_manager
|
||||
)
|
||||
|
||||
|
||||
@@ -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-----
|
||||
|
||||
Reference in New Issue
Block a user