правки по лаунчеру
This commit is contained in:
@@ -7,8 +7,10 @@ without excessive grind and with stronger social play.
|
|||||||
The launcher is responsible for:
|
The launcher is responsible for:
|
||||||
|
|
||||||
- logging in to a player's MoonWell account
|
- logging in to a player's MoonWell account
|
||||||
|
- registering new MoonWell game accounts
|
||||||
- downloading and synchronizing client files
|
- downloading and synchronizing client files
|
||||||
- displaying launcher news
|
- displaying launcher news
|
||||||
|
- displaying the live status of the first configured realm
|
||||||
- launching the game client
|
- launching the game client
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
@@ -30,20 +32,20 @@ and all Dart and Flutter commands are run through FVM.
|
|||||||
fvm flutter pub get
|
fvm flutter pub get
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify the configured SDK with `fvm flutter --version`.
|
Verify the configured SDK with `flutter --version`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Set the launcher Web API base URL at compile time:
|
Set the launcher Web API base URL at compile time:
|
||||||
|
|
||||||
```powershell
|
```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:
|
Set the public AppCast feed URL to enable Windows launcher self-updates:
|
||||||
|
|
||||||
```powershell
|
```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
|
The updater is disabled when `MOONWELL_APPCAST_URL` is omitted. Production
|
||||||
@@ -52,7 +54,7 @@ feeds must use HTTPS.
|
|||||||
Example run command:
|
Example run command:
|
||||||
|
|
||||||
```powershell
|
```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
|
## Development
|
||||||
@@ -61,8 +63,8 @@ Run the component catalog from its standalone workspace:
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd widgetbook
|
cd widgetbook
|
||||||
fvm dart run build_runner build
|
dart run build_runner build
|
||||||
fvm flutter run -d windows
|
flutter run -d windows
|
||||||
```
|
```
|
||||||
|
|
||||||
See `docs/widgetbook.md` for the required use-case naming, knobs, callback,
|
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:
|
Regenerate dependency injection after changing injectable services:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
fvm dart run build_runner build --delete-conflicting-outputs
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
```
|
```
|
||||||
|
|
||||||
Run static analysis:
|
Run static analysis:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
fvm flutter analyze
|
flutter analyze
|
||||||
```
|
```
|
||||||
|
|
||||||
See `docs/linting.md` for the full format, analyze, test, and Lefthook
|
See `docs/linting.md` for the full format, analyze, test, and Lefthook
|
||||||
@@ -86,13 +88,26 @@ pre-commit workflow.
|
|||||||
Build the Windows launcher:
|
Build the Windows launcher:
|
||||||
|
|
||||||
```powershell
|
```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`.
|
The Inno Setup installer script lives at `installer/moonwell_launcher.iss`.
|
||||||
See `docs/launcher_self_update.md` for the AppCast format, signing keys, and
|
See `docs/launcher_self_update.md` for the AppCast format, signing keys, and
|
||||||
manual release procedure.
|
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
|
## Architecture
|
||||||
|
|
||||||
The UI lives in `lib/app`. Presentation-only design-system components live in
|
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:
|
The launcher sync flow is manifest-based:
|
||||||
|
|
||||||
1. authenticate against the MoonWell Web API
|
1. authenticate against the MoonWell Web API
|
||||||
2. fetch the client manifest
|
2. fetch the client manifest and automatically synchronize a saved client
|
||||||
3. scan the selected installation directory
|
installation
|
||||||
4. compare local files to the manifest
|
3. refresh the manifest every five minutes while the launcher is open and
|
||||||
5. remove stale files outside ignored directories
|
automatically synchronize when the server build changes
|
||||||
6. download missing or changed files to temporary part files
|
4. scan the selected installation directory
|
||||||
7. verify size and SHA-256 before replacing client files
|
5. compare local files to the manifest
|
||||||
8. launch `Wow.exe` from the selected client directory
|
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.
|
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
|
upgrade feasibility notes
|
||||||
- `docs/launcher_web_api_spec.md`: current launcher Web API and sync behavior
|
- `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/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
|
- `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:
|
Pass the public feed URL at build time:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
fvm flutter build windows `
|
flutter build windows `
|
||||||
--dart-define=MOONWELL_API_BASE_URL=https://host `
|
--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online `
|
||||||
--dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml
|
--dart-define=MOONWELL_APPCAST_URL=https://moon-well.online/appcast.xml
|
||||||
```
|
```
|
||||||
|
|
||||||
The feed URL must be an absolute HTTPS URL. Omit
|
The feed URL must be an absolute HTTPS URL. Omit
|
||||||
@@ -67,10 +67,9 @@ Use a public AppCast with absolute URLs:
|
|||||||
<language>ru</language>
|
<language>ru</language>
|
||||||
<item>
|
<item>
|
||||||
<title>MoonWell Launcher 1.1.0</title>
|
<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>
|
<pubDate>Sun, 21 Jun 2026 12:00:00 +0400</pubDate>
|
||||||
<enclosure
|
<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:version="1.1.0"
|
||||||
sparkle:os="windows"
|
sparkle:os="windows"
|
||||||
sparkle:dsaSignature="SIGNATURE_FROM_SIGN_UPDATE"
|
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
|
Perform Windows signing on the Windows release machine because the package
|
||||||
invokes WinSparkle's Windows signing utility.
|
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.
|
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.
|
3. Build Windows with both production `--dart-define` values.
|
||||||
4. Compile the installer with `ISCC.exe /DMyAppVersion=X.Y.Z`.
|
4. Compile the installer with `ISCC.exe /DMyAppVersion=X.Y.Z`.
|
||||||
5. Make the private key available outside the repository and sign the artifact:
|
5. Make the private key available outside the repository and sign the artifact:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
fvm dart run auto_updater:sign_update `
|
dart run auto_updater:sign_update `
|
||||||
build\installer\moonwell_launcher_X.Y.Z_windows_setup.exe `
|
build\installer\moonwell_launcher_X.Y.Z_windows_setup.exe `
|
||||||
C:\secure-path\dsa_priv.pem
|
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
|
(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
|
8. Download the hosted installer, verify its byte length, and test its DSA
|
||||||
signature before changing the feed.
|
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
|
Keep the preceding installer available for rollback and support. Rollback is a
|
||||||
new release with a product version higher than the faulty release; WinSparkle
|
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:
|
Current integration mode is compile-time:
|
||||||
|
|
||||||
- `fvm flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://host`
|
- `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 build windows --dart-define=MOONWELL_API_BASE_URL=https://host`
|
||||||
|
|
||||||
## API Flow
|
## API Flow
|
||||||
|
|
||||||
The launcher uses these endpoints from `openapi.json`:
|
The launcher uses these endpoints from `openapi.json`:
|
||||||
|
|
||||||
1. `POST /api/launcher/login`
|
1. `POST /api/launcher/login`
|
||||||
2. `GET /api/launcher/manifest`
|
2. `POST /api/launcher/register`
|
||||||
3. `GET /api/launcher/download/{path}`
|
3. `GET /api/launcher/manifest`
|
||||||
4. `GET /api/launcher/news`
|
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:
|
Authentication sequence:
|
||||||
|
|
||||||
@@ -41,6 +54,11 @@ Authentication sequence:
|
|||||||
4. Successful login persists `LauncherSession` locally.
|
4. Successful login persists `LauncherSession` locally.
|
||||||
5. On next launcher start, saved session is reused to fetch manifest again.
|
5. On next launcher start, saved session is reused to fetch manifest again.
|
||||||
6. `LauncherSession` and `ClientManifest` are passed into the home screen.
|
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:
|
Logout sequence:
|
||||||
|
|
||||||
@@ -65,6 +83,28 @@ News sequence:
|
|||||||
2. The response payload is read from the top-level `data` array.
|
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`.
|
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.
|
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
|
## Installation Directory Rules
|
||||||
|
|
||||||
@@ -193,6 +233,10 @@ When the user presses `Play`:
|
|||||||
1. launcher resolves `<install-root>/Wow.exe`
|
1. launcher resolves `<install-root>/Wow.exe`
|
||||||
2. launcher clears `<install-root>/Cache`
|
2. launcher clears `<install-root>/Cache`
|
||||||
3. launcher starts `Wow.exe` with working directory set to installation root
|
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.
|
If `Wow.exe` is missing, launch fails with an error.
|
||||||
|
|
||||||
@@ -202,6 +246,7 @@ The launcher surfaces errors for:
|
|||||||
|
|
||||||
- invalid API configuration
|
- invalid API configuration
|
||||||
- authentication failure
|
- authentication failure
|
||||||
|
- registration validation or server failure
|
||||||
- manifest load failure
|
- manifest load failure
|
||||||
- path traversal attempts
|
- path traversal attempts
|
||||||
- checksum mismatch after download
|
- 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/design_system/mw_shells.dart';
|
||||||
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
||||||
|
|
||||||
|
enum MwAuthenticationMode { login, registration }
|
||||||
|
|
||||||
class MwAuthenticationForm extends StatefulWidget {
|
class MwAuthenticationForm extends StatefulWidget {
|
||||||
const MwAuthenticationForm({
|
const MwAuthenticationForm({
|
||||||
super.key,
|
super.key,
|
||||||
required this.usernameController,
|
required this.usernameController,
|
||||||
required this.passwordController,
|
required this.passwordController,
|
||||||
required this.onSubmit,
|
required this.onSubmit,
|
||||||
|
this.emailController,
|
||||||
|
this.passwordConfirmationController,
|
||||||
|
this.inviteCodeController,
|
||||||
|
this.mode = MwAuthenticationMode.login,
|
||||||
|
this.onModeChanged,
|
||||||
|
this.termsAccepted = false,
|
||||||
|
this.onTermsChanged,
|
||||||
this.loading = false,
|
this.loading = false,
|
||||||
this.error,
|
this.error,
|
||||||
|
this.success,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController usernameController;
|
final TextEditingController usernameController;
|
||||||
final TextEditingController passwordController;
|
final TextEditingController passwordController;
|
||||||
|
final TextEditingController? emailController;
|
||||||
|
final TextEditingController? passwordConfirmationController;
|
||||||
|
final TextEditingController? inviteCodeController;
|
||||||
final VoidCallback onSubmit;
|
final VoidCallback onSubmit;
|
||||||
|
final MwAuthenticationMode mode;
|
||||||
|
final ValueChanged<MwAuthenticationMode>? onModeChanged;
|
||||||
|
final bool termsAccepted;
|
||||||
|
final ValueChanged<bool>? onTermsChanged;
|
||||||
final bool loading;
|
final bool loading;
|
||||||
final String? error;
|
final String? error;
|
||||||
|
final String? success;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MwAuthenticationForm> createState() => _MwAuthenticationFormState();
|
State<MwAuthenticationForm> createState() => _MwAuthenticationFormState();
|
||||||
@@ -28,6 +46,7 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final accent = MoonWellDesignTokens.of(context).accent;
|
final accent = MoonWellDesignTokens.of(context).accent;
|
||||||
|
final registering = widget.mode == MwAuthenticationMode.registration;
|
||||||
return AutofillGroup(
|
return AutofillGroup(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -35,13 +54,13 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
|
|||||||
const MwBrandMark(width: 210, compact: true),
|
const MwBrandMark(width: 210, compact: true),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
Text(
|
Text(
|
||||||
'ВХОД',
|
registering ? 'РЕГИСТРАЦИЯ' : 'ВХОД',
|
||||||
style: TextStyle(color: accent, fontSize: 10, letterSpacing: 2.4),
|
style: TextStyle(color: accent, fontSize: 10, letterSpacing: 2.4),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
const Text(
|
Text(
|
||||||
'С возвращением',
|
registering ? 'Создать аккаунт' : 'С возвращением',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'Cinzel',
|
fontFamily: 'Cinzel',
|
||||||
color: Color(0xFFECE4D0),
|
color: Color(0xFFECE4D0),
|
||||||
fontSize: 30,
|
fontSize: 30,
|
||||||
@@ -49,9 +68,11 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 7),
|
const SizedBox(height: 7),
|
||||||
const Text(
|
Text(
|
||||||
'Войди в свой аккаунт, чтобы продолжить путь.',
|
registering
|
||||||
style: TextStyle(
|
? 'Зарегистрируй игровой аккаунт MoonWell.'
|
||||||
|
: 'Войди в свой аккаунт, чтобы продолжить путь.',
|
||||||
|
style: const TextStyle(
|
||||||
fontFamily: 'Cormorant Garamond',
|
fontFamily: 'Cormorant Garamond',
|
||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
color: Color(0x80ECE4D0),
|
color: Color(0x80ECE4D0),
|
||||||
@@ -61,12 +82,30 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
|
|||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
_Tab(label: 'ВХОД', selected: true, accent: accent),
|
_Tab(
|
||||||
_Tab(label: 'РЕГИСТРАЦИЯ', accent: accent),
|
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),
|
const SizedBox(height: 18),
|
||||||
_FieldLabel(text: 'ЛОГИН ИЛИ EMAIL'),
|
_FieldLabel(text: registering ? 'ЛОГИН' : 'ЛОГИН ИЛИ EMAIL'),
|
||||||
const SizedBox(height: 7),
|
const SizedBox(height: 7),
|
||||||
TextField(
|
TextField(
|
||||||
controller: widget.usernameController,
|
controller: widget.usernameController,
|
||||||
@@ -75,6 +114,19 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
|
|||||||
autofillHints: const [AutofillHints.username],
|
autofillHints: const [AutofillHints.username],
|
||||||
decoration: const InputDecoration(hintText: 'Aranthel'),
|
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),
|
const SizedBox(height: 14),
|
||||||
_FieldLabel(text: 'ПАРОЛЬ'),
|
_FieldLabel(text: 'ПАРОЛЬ'),
|
||||||
const SizedBox(height: 7),
|
const SizedBox(height: 7),
|
||||||
@@ -82,10 +134,35 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
|
|||||||
controller: widget.passwordController,
|
controller: widget.passwordController,
|
||||||
enabled: !widget.loading,
|
enabled: !widget.loading,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: registering
|
||||||
autofillHints: const [AutofillHints.password],
|
? TextInputAction.next
|
||||||
onSubmitted: (_) => widget.onSubmit(),
|
: 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) ...[
|
if (widget.error != null) ...[
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(
|
Text(
|
||||||
@@ -93,30 +170,61 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
|
|||||||
style: const TextStyle(color: Color(0xFFD76A78), fontSize: 12),
|
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),
|
const SizedBox(height: 8),
|
||||||
Row(
|
if (registering)
|
||||||
children: [
|
Row(
|
||||||
SizedBox(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
width: 24,
|
children: [
|
||||||
child: Checkbox(
|
SizedBox(
|
||||||
value: _remember,
|
width: 24,
|
||||||
onChanged: widget.loading
|
child: Checkbox(
|
||||||
? null
|
value: widget.termsAccepted,
|
||||||
: (value) => setState(() => _remember = value ?? false),
|
onChanged: widget.loading
|
||||||
|
? null
|
||||||
|
: (value) =>
|
||||||
|
widget.onTermsChanged?.call(value ?? false),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(width: 8),
|
const Expanded(
|
||||||
const Text(
|
child: Text(
|
||||||
'Запомнить меня',
|
'Я принимаю правила сервера и пользовательское соглашение',
|
||||||
style: TextStyle(color: Color(0x9AECE4D0), fontSize: 11),
|
style: TextStyle(color: Color(0x9AECE4D0), fontSize: 11),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
),
|
||||||
Text(
|
],
|
||||||
'Забыли пароль?',
|
)
|
||||||
style: TextStyle(color: accent, 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),
|
const SizedBox(height: 18),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 54,
|
height: 54,
|
||||||
@@ -134,18 +242,18 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
|
|||||||
dimension: 18,
|
dimension: 18,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Row(
|
: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'ВОЙТИ',
|
registering ? 'СОЗДАТЬ АККАУНТ' : 'ВОЙТИ',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'Cinzel',
|
fontFamily: 'Cinzel',
|
||||||
letterSpacing: 2.5,
|
letterSpacing: 2.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
Icon(Icons.arrow_forward, size: 18),
|
const Icon(Icons.arrow_forward, size: 18),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -153,7 +261,7 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
|
|||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
const Center(
|
const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'MOONWELL · V 1.0.0 · LOGIN.MOONWELL.GG',
|
'MOONWELL · V 1.0.0 · PLAY.MOON-WELL.ONLINE',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
color: Color(0x52ECE4D0),
|
color: Color(0x52ECE4D0),
|
||||||
@@ -187,30 +295,35 @@ class _Tab extends StatelessWidget {
|
|||||||
const _Tab({
|
const _Tab({
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.accent,
|
required this.accent,
|
||||||
|
this.onTap,
|
||||||
this.selected = false,
|
this.selected = false,
|
||||||
});
|
});
|
||||||
final String label;
|
final String label;
|
||||||
final Color accent;
|
final Color accent;
|
||||||
|
final VoidCallback? onTap;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Expanded(
|
Widget build(BuildContext context) => Expanded(
|
||||||
child: Container(
|
child: InkWell(
|
||||||
height: 38,
|
onTap: onTap,
|
||||||
alignment: Alignment.center,
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
height: 38,
|
||||||
border: Border(
|
alignment: Alignment.center,
|
||||||
bottom: BorderSide(
|
decoration: BoxDecoration(
|
||||||
color: selected ? accent : const Color(0x247FB8D4),
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: selected ? accent : const Color(0x247FB8D4),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
child: Text(
|
||||||
child: Text(
|
label,
|
||||||
label,
|
style: TextStyle(
|
||||||
style: TextStyle(
|
fontFamily: 'Cinzel',
|
||||||
fontFamily: 'Cinzel',
|
color: selected ? const Color(0xFFECE4D0) : const Color(0x80ECE4D0),
|
||||||
color: selected ? const Color(0xFFECE4D0) : const Color(0x80ECE4D0),
|
fontSize: 9,
|
||||||
fontSize: 9,
|
letterSpacing: 1.5,
|
||||||
letterSpacing: 1.5,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ class MwGameTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!enabled)
|
if (!enabled && subtitle == null)
|
||||||
const Text(
|
const Text(
|
||||||
'SOON',
|
'SOON',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -121,13 +121,24 @@ class MwGameTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MwGameRail 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 bool compact;
|
||||||
|
final VoidCallback? onSettings;
|
||||||
|
final String? username;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final accent = MoonWellDesignTokens.of(context).accent;
|
final accent = MoonWellDesignTokens.of(context).accent;
|
||||||
|
final normalizedUsername = username?.trim() ?? '';
|
||||||
|
final userInitial = normalizedUsername.isEmpty
|
||||||
|
? '?'
|
||||||
|
: normalizedUsername.characters.first.toUpperCase();
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.fromLTRB(14, 48, 14, 30),
|
padding: const EdgeInsets.fromLTRB(14, 48, 14, 30),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
@@ -154,7 +165,10 @@ class MwGameRail extends StatelessWidget {
|
|||||||
BoxShadow(color: accent.withAlpha(70), blurRadius: 12),
|
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 SizedBox(height: 14),
|
||||||
const Divider(color: _border),
|
const Divider(color: _border),
|
||||||
@@ -183,28 +197,13 @@ class MwGameRail extends StatelessWidget {
|
|||||||
onPressed: () {},
|
onPressed: () {},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const MwGameTile(
|
MwGameTile(
|
||||||
title: 'MoonWell Karts',
|
title: 'MoonWell Karts',
|
||||||
subtitle: 'Closed Alpha',
|
subtitle: 'В разработке',
|
||||||
mark: 'K',
|
mark: 'K',
|
||||||
icon: Icons.sports_motorsports,
|
icon: Icons.sports_motorsports,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
),
|
compact: compact,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
const Divider(color: _border),
|
const Divider(color: _border),
|
||||||
@@ -216,12 +215,15 @@ class MwGameRail extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.people_outline, size: 18),
|
icon: const Icon(Icons.people_outline, size: 18),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Icon(
|
IconButton(
|
||||||
Icons.settings_outlined,
|
onPressed: onSettings,
|
||||||
color: accent.withAlpha(150),
|
tooltip: 'Настройки',
|
||||||
size: 18,
|
icon: Icon(
|
||||||
|
Icons.settings_outlined,
|
||||||
|
color: accent.withAlpha(150),
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -236,11 +238,13 @@ class MwNewsItemData {
|
|||||||
required this.body,
|
required this.body,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
this.imageUrl,
|
this.imageUrl,
|
||||||
|
this.onPressed,
|
||||||
});
|
});
|
||||||
final String title;
|
final String title;
|
||||||
final String body;
|
final String body;
|
||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
final String? imageUrl;
|
final String? imageUrl;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
}
|
}
|
||||||
|
|
||||||
class MwNewsItem extends StatelessWidget {
|
class MwNewsItem extends StatelessWidget {
|
||||||
@@ -251,55 +255,64 @@ class MwNewsItem extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final accent = MoonWellDesignTokens.of(context).accent;
|
final accent = MoonWellDesignTokens.of(context).accent;
|
||||||
final date = item.createdAt?.toLocal();
|
final date = item.createdAt?.toLocal();
|
||||||
return Padding(
|
return Material(
|
||||||
padding: const EdgeInsets.fromLTRB(0, 13, 12, 13),
|
color: Colors.transparent,
|
||||||
child: Column(
|
child: InkWell(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
onTap: item.onPressed,
|
||||||
children: [
|
child: Padding(
|
||||||
Row(
|
padding: const EdgeInsets.fromLTRB(0, 13, 12, 13),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Row(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
children: [
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
border: Border.all(color: accent),
|
padding: const EdgeInsets.symmetric(
|
||||||
borderRadius: BorderRadius.circular(3),
|
horizontal: 8,
|
||||||
),
|
vertical: 3,
|
||||||
child: Text(
|
),
|
||||||
'НОВОСТЬ',
|
decoration: BoxDecoration(
|
||||||
style: TextStyle(
|
border: Border.all(color: accent),
|
||||||
fontFamily: 'JetBrains Mono',
|
borderRadius: BorderRadius.circular(3),
|
||||||
color: accent,
|
),
|
||||||
fontSize: 8,
|
child: Text(
|
||||||
letterSpacing: 1.5,
|
'НОВОСТЬ',
|
||||||
|
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(
|
Text(
|
||||||
date == null
|
item.title,
|
||||||
? 'СЕГОДНЯ'
|
maxLines: 2,
|
||||||
: '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}',
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'Cormorant Garamond',
|
||||||
color: _muted,
|
color: _ivory,
|
||||||
fontSize: 9,
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_NewsTab(label: 'НОВОСТИ', selected: true, accent: accent),
|
_NewsTab(label: 'НОВОСТИ', selected: true, accent: accent),
|
||||||
_NewsTab(label: 'СОБЫТИЯ', accent: accent),
|
|
||||||
_NewsTab(label: 'ПАТЧИ', accent: accent),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -353,7 +364,7 @@ class MwNewsList extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'MOONWELL.GG',
|
'MOON-WELL.ONLINE',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
color: _muted,
|
color: _muted,
|
||||||
@@ -455,6 +466,9 @@ class MwSyncActionArea extends StatelessWidget {
|
|||||||
this.progress,
|
this.progress,
|
||||||
this.currentPath,
|
this.currentPath,
|
||||||
this.error,
|
this.error,
|
||||||
|
this.realmName,
|
||||||
|
this.realmOnline,
|
||||||
|
this.realmGameBuild,
|
||||||
});
|
});
|
||||||
final MwSyncPresentation state;
|
final MwSyncPresentation state;
|
||||||
final String status;
|
final String status;
|
||||||
@@ -463,6 +477,9 @@ class MwSyncActionArea extends StatelessWidget {
|
|||||||
final double? progress;
|
final double? progress;
|
||||||
final String? currentPath;
|
final String? currentPath;
|
||||||
final String? error;
|
final String? error;
|
||||||
|
final String? realmName;
|
||||||
|
final bool? realmOnline;
|
||||||
|
final int? realmGameBuild;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -477,6 +494,14 @@ class MwSyncActionArea extends StatelessWidget {
|
|||||||
final value = ready || state == MwSyncPresentation.launched
|
final value = ready || state == MwSyncPresentation.launched
|
||||||
? 1.0
|
? 1.0
|
||||||
: (progress ?? 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) {
|
final label = switch (state) {
|
||||||
MwSyncPresentation.install => 'УСТАНОВИТЬ',
|
MwSyncPresentation.install => 'УСТАНОВИТЬ',
|
||||||
MwSyncPresentation.paused => 'ПРОДОЛЖИТЬ',
|
MwSyncPresentation.paused => 'ПРОДОЛЖИТЬ',
|
||||||
@@ -503,26 +528,28 @@ class MwSyncActionArea extends StatelessWidget {
|
|||||||
border: Border.all(color: _border),
|
border: Border.all(color: _border),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: const Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_PulseDot(),
|
_PulseDot(online: realmOnline),
|
||||||
SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"ELUNE'S GRACE",
|
(realmName?.trim().isNotEmpty ?? false)
|
||||||
style: TextStyle(
|
? realmName!.toUpperCase()
|
||||||
|
: 'REALM UNAVAILABLE',
|
||||||
|
style: const TextStyle(
|
||||||
fontFamily: 'Cinzel',
|
fontFamily: 'Cinzel',
|
||||||
color: _ivory,
|
color: _ivory,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
letterSpacing: 1.5,
|
letterSpacing: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Text(
|
Text(
|
||||||
'ONLINE · EU',
|
realmMetadata,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
color: _muted,
|
color: _muted,
|
||||||
fontSize: 8,
|
fontSize: 8,
|
||||||
@@ -592,14 +619,20 @@ class MwSyncActionArea extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
if (ready)
|
||||||
ready ? 'Последняя проверка: только что' : '',
|
const Flexible(
|
||||||
style: const TextStyle(
|
child: Text(
|
||||||
fontFamily: 'JetBrains Mono',
|
'Последняя проверка: только что',
|
||||||
color: _muted,
|
maxLines: 1,
|
||||||
fontSize: 8,
|
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 {
|
class _PulseDot extends StatelessWidget {
|
||||||
const _PulseDot();
|
const _PulseDot({required this.online});
|
||||||
|
|
||||||
|
final bool? online;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Container(
|
Widget build(BuildContext context) {
|
||||||
width: 8,
|
final color = switch (online) {
|
||||||
height: 8,
|
true => const Color(0xFF6FC28A),
|
||||||
decoration: const BoxDecoration(
|
false => const Color(0xFFD76A78),
|
||||||
color: Color(0xFF6FC28A),
|
null => _muted,
|
||||||
shape: BoxShape.circle,
|
};
|
||||||
boxShadow: [BoxShadow(color: Color(0xAA6FC28A), blurRadius: 8)],
|
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 {
|
class MwLauncherStatusBar extends StatelessWidget {
|
||||||
@@ -709,87 +752,67 @@ class MwLauncherStatusBar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MwAccountAffordance extends StatelessWidget {
|
class MwAccountAffordance extends StatelessWidget {
|
||||||
const MwAccountAffordance({
|
const MwAccountAffordance({super.key, required this.onLogout, this.username});
|
||||||
super.key,
|
|
||||||
required this.onLogout,
|
|
||||||
this.onSettings,
|
|
||||||
});
|
|
||||||
final VoidCallback onLogout;
|
final VoidCallback onLogout;
|
||||||
final VoidCallback? onSettings;
|
final String? username;
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => PopupMenuButton<String>(
|
Widget build(BuildContext context) {
|
||||||
tooltip: 'Аккаунт',
|
final displayUsername = (username?.trim().isNotEmpty ?? false)
|
||||||
onSelected: (value) =>
|
? username!.trim()
|
||||||
value == 'settings' ? onSettings?.call() : onLogout(),
|
: 'Аккаунт';
|
||||||
itemBuilder: (_) => const [
|
final initial = displayUsername.characters.first.toUpperCase();
|
||||||
PopupMenuItem(value: 'settings', child: Text('Настройки')),
|
|
||||||
PopupMenuItem(value: 'logout', child: Text('Выйти')),
|
return PopupMenuButton<String>(
|
||||||
],
|
tooltip: 'Аккаунт',
|
||||||
child: Container(
|
onSelected: (_) => onLogout(),
|
||||||
padding: const EdgeInsets.fromLTRB(8, 7, 15, 7),
|
itemBuilder: (_) => const [
|
||||||
decoration: BoxDecoration(
|
PopupMenuItem(value: 'logout', child: Text('Выйти')),
|
||||||
color: const Color(0x99070A18),
|
],
|
||||||
border: Border.all(color: _border),
|
child: Container(
|
||||||
borderRadius: BorderRadius.circular(99),
|
padding: const EdgeInsets.fromLTRB(8, 7, 15, 7),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
child: const Row(
|
color: const Color(0x99070A18),
|
||||||
children: [
|
border: Border.all(color: _border),
|
||||||
CircleAvatar(
|
borderRadius: BorderRadius.circular(99),
|
||||||
radius: 18,
|
),
|
||||||
backgroundColor: Color(0xFF5B3A8A),
|
child: Row(
|
||||||
child: Text(
|
children: [
|
||||||
'Æ',
|
CircleAvatar(
|
||||||
style: TextStyle(fontFamily: 'Cinzel', fontSize: 12),
|
radius: 18,
|
||||||
|
backgroundColor: const Color(0xFF5B3A8A),
|
||||||
|
child: Text(
|
||||||
|
initial,
|
||||||
|
style: const TextStyle(fontFamily: 'Cinzel', fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 11),
|
||||||
SizedBox(width: 11),
|
Text(
|
||||||
Column(
|
displayUsername,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
style: const TextStyle(
|
||||||
children: [
|
fontFamily: 'Cinzel',
|
||||||
Text(
|
color: _ivory,
|
||||||
'Aranthel',
|
fontSize: 12,
|
||||||
style: TextStyle(
|
letterSpacing: 1,
|
||||||
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 {
|
class MwSettingsPanel extends StatelessWidget {
|
||||||
const MwSettingsPanel({
|
const MwSettingsPanel({
|
||||||
super.key,
|
super.key,
|
||||||
required this.installationPath,
|
required this.installationPath,
|
||||||
required this.themeVariant,
|
|
||||||
required this.onChooseDirectory,
|
required this.onChooseDirectory,
|
||||||
required this.onThemeChanged,
|
|
||||||
required this.onVerify,
|
required this.onVerify,
|
||||||
required this.onLogout,
|
|
||||||
this.syncActive = false,
|
this.syncActive = false,
|
||||||
});
|
});
|
||||||
final String? installationPath;
|
final String? installationPath;
|
||||||
final MoonWellThemeVariant themeVariant;
|
|
||||||
final VoidCallback onChooseDirectory;
|
final VoidCallback onChooseDirectory;
|
||||||
final ValueChanged<MoonWellThemeVariant> onThemeChanged;
|
|
||||||
final VoidCallback onVerify;
|
final VoidCallback onVerify;
|
||||||
final VoidCallback onLogout;
|
|
||||||
final bool syncActive;
|
final bool syncActive;
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => MwPanel(
|
Widget build(BuildContext context) => MwPanel(
|
||||||
@@ -812,32 +835,11 @@ class MwSettingsPanel extends StatelessWidget {
|
|||||||
variant: MwButtonVariant.secondary,
|
variant: MwButtonVariant.secondary,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
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(
|
MwButton(
|
||||||
label: 'Проверить файлы',
|
label: 'Проверить файлы',
|
||||||
onPressed: syncActive ? null : onVerify,
|
onPressed: syncActive ? null : onVerify,
|
||||||
variant: MwButtonVariant.secondary,
|
variant: MwButtonVariant.secondary,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
MwButton(
|
|
||||||
label: 'Выйти',
|
|
||||||
onPressed: onLogout,
|
|
||||||
variant: MwButtonVariant.destructive,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,40 +20,44 @@ class MwBrandMark extends StatelessWidget {
|
|||||||
label: 'MoonWell',
|
label: 'MoonWell',
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: width,
|
width: width,
|
||||||
child: Row(
|
child: FittedBox(
|
||||||
mainAxisSize: MainAxisSize.min,
|
fit: BoxFit.scaleDown,
|
||||||
children: [
|
alignment: Alignment.centerLeft,
|
||||||
_MoonMark(size: compact ? 28 : 40, color: accent),
|
child: Row(
|
||||||
const SizedBox(width: 12),
|
mainAxisSize: MainAxisSize.min,
|
||||||
Column(
|
children: [
|
||||||
mainAxisSize: MainAxisSize.min,
|
_MoonMark(size: compact ? 28 : 40, color: accent),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
const SizedBox(width: 12),
|
||||||
children: [
|
Column(
|
||||||
Text(
|
mainAxisSize: MainAxisSize.min,
|
||||||
'MOONWELL',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(
|
children: [
|
||||||
fontFamily: 'Cinzel',
|
|
||||||
color: _ivory,
|
|
||||||
fontSize: compact ? 15 : 22,
|
|
||||||
height: 1,
|
|
||||||
letterSpacing: compact ? 4 : 6,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!compact) ...[
|
|
||||||
const SizedBox(height: 5),
|
|
||||||
Text(
|
Text(
|
||||||
'LUNAR · LAUNCHER',
|
'MOONWELL',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'Cinzel',
|
||||||
color: accent,
|
color: _ivory,
|
||||||
fontSize: 9,
|
fontSize: compact ? 15 : 22,
|
||||||
letterSpacing: 2.6,
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Stack(
|
||||||
padding: const EdgeInsets.fromLTRB(64, 80, 64, 58),
|
fit: StackFit.expand,
|
||||||
child: Align(
|
children: [
|
||||||
alignment: Alignment.bottomLeft,
|
Image.asset(
|
||||||
child: ConstrainedBox(
|
'assets/login_key_art.png',
|
||||||
constraints: const BoxConstraints(maxWidth: 500),
|
package: assetPackage,
|
||||||
child: const Column(
|
fit: BoxFit.cover,
|
||||||
mainAxisSize: MainAxisSize.min,
|
alignment: Alignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||||
children: [
|
),
|
||||||
Text(
|
const DecoratedBox(
|
||||||
'✦',
|
decoration: BoxDecoration(
|
||||||
style: TextStyle(
|
gradient: LinearGradient(
|
||||||
color: Color(0xFF7FB8D4),
|
begin: Alignment.topCenter,
|
||||||
fontSize: 20,
|
end: Alignment.bottomCenter,
|
||||||
),
|
stops: [0, .52, 1],
|
||||||
),
|
colors: [
|
||||||
SizedBox(height: 12),
|
Color(0x00070A18),
|
||||||
Text(
|
Color(0x22070A18),
|
||||||
'«Звёздный свет проведёт тебя сквозь самые '
|
Color(0xF2070A18),
|
||||||
'тёмные ночи — если ты готов идти туда, '
|
],
|
||||||
'куда он указывает.»',
|
),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
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(
|
Container(
|
||||||
@@ -292,6 +335,8 @@ class MwLauncherShell extends StatelessWidget {
|
|||||||
required this.onMinimize,
|
required this.onMinimize,
|
||||||
required this.onMaximizeRestore,
|
required this.onMaximizeRestore,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
|
this.onSettings,
|
||||||
|
this.username,
|
||||||
this.assetPackage,
|
this.assetPackage,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -304,6 +349,8 @@ class MwLauncherShell extends StatelessWidget {
|
|||||||
final VoidCallback onMinimize;
|
final VoidCallback onMinimize;
|
||||||
final VoidCallback onMaximizeRestore;
|
final VoidCallback onMaximizeRestore;
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback? onSettings;
|
||||||
|
final String? username;
|
||||||
final String? assetPackage;
|
final String? assetPackage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -313,12 +360,57 @@ class MwLauncherShell extends StatelessWidget {
|
|||||||
assetPackage: assetPackage,
|
assetPackage: assetPackage,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
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,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: 240,
|
width: 240,
|
||||||
child: MwGameRail(),
|
child: MwGameRail(onSettings: onSettings, username: username),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 272,
|
left: 272,
|
||||||
|
|||||||
@@ -26,11 +26,14 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
required PreferencesRepository preferencesRepository,
|
required PreferencesRepository preferencesRepository,
|
||||||
required LauncherSession session,
|
required LauncherSession session,
|
||||||
required ClientManifest manifest,
|
required ClientManifest manifest,
|
||||||
|
Duration manifestRefreshInterval = const Duration(minutes: 5),
|
||||||
|
Duration realmRefreshInterval = const Duration(minutes: 1),
|
||||||
}) : _clientSyncUseCase = clientSyncUseCase,
|
}) : _clientSyncUseCase = clientSyncUseCase,
|
||||||
_gameInstallationService = gameInstallationService,
|
_gameInstallationService = gameInstallationService,
|
||||||
_launcherApiClient = launcherApiClient,
|
_launcherApiClient = launcherApiClient,
|
||||||
_preferencesRepository = preferencesRepository,
|
_preferencesRepository = preferencesRepository,
|
||||||
_session = session,
|
_session = session,
|
||||||
|
_startupManifest = manifest,
|
||||||
super(
|
super(
|
||||||
HomeScreenState(
|
HomeScreenState(
|
||||||
model: HomeScreenModel.initial().copyWith(
|
model: HomeScreenModel.initial().copyWith(
|
||||||
@@ -41,14 +44,28 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
) {
|
) {
|
||||||
on<HomeScreenLoad>(_onHomeScreenLoad);
|
on<HomeScreenLoad>(_onHomeScreenLoad);
|
||||||
on<HomeScreenSyncRequested>(_onHomeScreenSyncRequested);
|
on<HomeScreenSyncRequested>(_onHomeScreenSyncRequested);
|
||||||
|
on<HomeScreenManifestRefreshRequested>(
|
||||||
|
_onHomeScreenManifestRefreshRequested,
|
||||||
|
);
|
||||||
|
on<HomeScreenRealmRefreshRequested>(_onHomeScreenRealmRefreshRequested);
|
||||||
|
on<HomeScreenAccountLoadRequested>(_onHomeScreenAccountLoadRequested);
|
||||||
on<HomeScreenOutputDirRequested>(_onHomeScreenOutputDirRequested);
|
on<HomeScreenOutputDirRequested>(_onHomeScreenOutputDirRequested);
|
||||||
on<HomeScreenPauseRequested>(_onHomeScreenPauseRequested);
|
on<HomeScreenPauseRequested>(_onHomeScreenPauseRequested);
|
||||||
on<HomeScreenPlayRequested>(_onHomeScreenPlayRequested);
|
on<HomeScreenPlayRequested>(_onHomeScreenPlayRequested);
|
||||||
|
on<HomeScreenGameExited>(_onHomeScreenGameExited);
|
||||||
on<HomeScreenLogoutRequested>(_onHomeScreenLogoutRequested);
|
on<HomeScreenLogoutRequested>(_onHomeScreenLogoutRequested);
|
||||||
on<HomeScreenSyncStatusChanged>(_onHomeScreenSyncStatusChanged);
|
on<HomeScreenSyncStatusChanged>(_onHomeScreenSyncStatusChanged);
|
||||||
on<HomeScreenSyncFailed>(_onHomeScreenSyncFailed);
|
on<HomeScreenSyncFailed>(_onHomeScreenSyncFailed);
|
||||||
|
|
||||||
add(HomeScreenLoad());
|
add(HomeScreenLoad());
|
||||||
|
_manifestRefreshTimer = Timer.periodic(
|
||||||
|
manifestRefreshInterval,
|
||||||
|
(_) => add(HomeScreenManifestRefreshRequested()),
|
||||||
|
);
|
||||||
|
_realmRefreshTimer = Timer.periodic(
|
||||||
|
realmRefreshInterval,
|
||||||
|
(_) => add(HomeScreenRealmRefreshRequested()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final ClientSyncUseCase _clientSyncUseCase;
|
final ClientSyncUseCase _clientSyncUseCase;
|
||||||
@@ -58,6 +75,10 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
|
|
||||||
StreamSubscription<ClientSyncStatus>? _syncSubscription;
|
StreamSubscription<ClientSyncStatus>? _syncSubscription;
|
||||||
final LauncherSession _session;
|
final LauncherSession _session;
|
||||||
|
final ClientManifest _startupManifest;
|
||||||
|
Timer? _manifestRefreshTimer;
|
||||||
|
Timer? _realmRefreshTimer;
|
||||||
|
int? _activeGamePid;
|
||||||
bool _pauseRequested = false;
|
bool _pauseRequested = false;
|
||||||
|
|
||||||
Future<void> _onHomeScreenLoad(
|
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 {
|
try {
|
||||||
final newsItems = await _launcherApiClient.fetchNews(
|
final newsItems = await _launcherApiClient.fetchNews(
|
||||||
_session.accessToken,
|
_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(
|
Future<void> _onHomeScreenSyncRequested(
|
||||||
HomeScreenSyncRequested event,
|
HomeScreenSyncRequested event,
|
||||||
Emitter<HomeScreenState> emit,
|
Emitter<HomeScreenState> emit,
|
||||||
) async {
|
) async {
|
||||||
if (state.model.isSyncing) {
|
if (state.model.isSyncing || state.model.isGameRunning) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +262,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
request: ClientSyncRequest(
|
request: ClientSyncRequest(
|
||||||
installationDir: outputPath.toFilePath(),
|
installationDir: outputPath.toFilePath(),
|
||||||
accessToken: _session.accessToken,
|
accessToken: _session.accessToken,
|
||||||
|
manifest: event.manifest,
|
||||||
),
|
),
|
||||||
isCancelled: () async => _pauseRequested,
|
isCancelled: () async => _pauseRequested,
|
||||||
),
|
),
|
||||||
@@ -228,12 +346,30 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
try {
|
try {
|
||||||
final installationDir = outputPath.toFilePath();
|
final installationDir = outputPath.toFilePath();
|
||||||
await _gameInstallationService.clearCache(installationDir);
|
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(
|
emit(
|
||||||
HomeScreenState(
|
HomeScreenState(
|
||||||
model: state.model.copyWith(
|
model: state.model.copyWith(
|
||||||
phase: HomeScreenPhase.readyToPlay,
|
phase: HomeScreenPhase.readyToPlay,
|
||||||
|
isGameRunning: true,
|
||||||
statusText: 'Игра запущена. Папка Cache очищена перед стартом.',
|
statusText: 'Игра запущена. Папка Cache очищена перед стартом.',
|
||||||
errorMessage: null,
|
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(
|
Future<void> _onHomeScreenLogoutRequested(
|
||||||
HomeScreenLogoutRequested event,
|
HomeScreenLogoutRequested event,
|
||||||
Emitter<HomeScreenState> emit,
|
Emitter<HomeScreenState> emit,
|
||||||
) async {
|
) async {
|
||||||
_pauseRequested = true;
|
_pauseRequested = true;
|
||||||
|
_activeGamePid = null;
|
||||||
await _syncSubscription?.cancel();
|
await _syncSubscription?.cancel();
|
||||||
_syncSubscription = null;
|
_syncSubscription = null;
|
||||||
await _preferencesRepository.clearLauncherSession();
|
await _preferencesRepository.clearLauncherSession();
|
||||||
@@ -263,6 +427,8 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
model: state.model.copyWith(
|
model: state.model.copyWith(
|
||||||
phase: HomeScreenPhase.idle,
|
phase: HomeScreenPhase.idle,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
isGameRunning: false,
|
||||||
|
accountUsername: null,
|
||||||
progress: const DownloadProgress.initial(),
|
progress: const DownloadProgress.initial(),
|
||||||
statusText: 'Сессия завершена. Войдите снова.',
|
statusText: 'Сессия завершена. Войдите снова.',
|
||||||
currentPath: null,
|
currentPath: null,
|
||||||
@@ -274,6 +440,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
newsItems: const [],
|
newsItems: const [],
|
||||||
isLoadingNews: false,
|
isLoadingNews: false,
|
||||||
newsErrorMessage: null,
|
newsErrorMessage: null,
|
||||||
|
realm: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -424,6 +591,9 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
@override
|
@override
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
_pauseRequested = true;
|
_pauseRequested = true;
|
||||||
|
_activeGamePid = null;
|
||||||
|
_manifestRefreshTimer?.cancel();
|
||||||
|
_realmRefreshTimer?.cancel();
|
||||||
await _syncSubscription?.cancel();
|
await _syncSubscription?.cancel();
|
||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,17 @@ sealed class HomeScreenEvent {}
|
|||||||
|
|
||||||
final class HomeScreenLoad extends 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 {}
|
final class HomeScreenOutputDirRequested extends HomeScreenEvent {}
|
||||||
|
|
||||||
@@ -13,6 +23,13 @@ final class HomeScreenPauseRequested extends HomeScreenEvent {}
|
|||||||
|
|
||||||
final class HomeScreenPlayRequested 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 HomeScreenLogoutRequested extends HomeScreenEvent {}
|
||||||
|
|
||||||
final class HomeScreenSyncStatusChanged 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/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/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_news_item.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
|
||||||
|
|
||||||
enum HomeScreenPhase {
|
enum HomeScreenPhase {
|
||||||
idle,
|
idle,
|
||||||
@@ -22,6 +23,8 @@ final class HomeScreenModel {
|
|||||||
final HomeScreenPhase phase;
|
final HomeScreenPhase phase;
|
||||||
final bool isAuthenticated;
|
final bool isAuthenticated;
|
||||||
final bool hasClientExecutable;
|
final bool hasClientExecutable;
|
||||||
|
final bool isGameRunning;
|
||||||
|
final String? accountUsername;
|
||||||
final String statusText;
|
final String statusText;
|
||||||
final String? currentPath;
|
final String? currentPath;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
@@ -32,6 +35,7 @@ final class HomeScreenModel {
|
|||||||
final List<LauncherNewsItem> newsItems;
|
final List<LauncherNewsItem> newsItems;
|
||||||
final bool isLoadingNews;
|
final bool isLoadingNews;
|
||||||
final String? newsErrorMessage;
|
final String? newsErrorMessage;
|
||||||
|
final LauncherRealm? realm;
|
||||||
final ClientSyncStage? syncStage;
|
final ClientSyncStage? syncStage;
|
||||||
|
|
||||||
const HomeScreenModel({
|
const HomeScreenModel({
|
||||||
@@ -40,6 +44,8 @@ final class HomeScreenModel {
|
|||||||
required this.phase,
|
required this.phase,
|
||||||
required this.isAuthenticated,
|
required this.isAuthenticated,
|
||||||
required this.hasClientExecutable,
|
required this.hasClientExecutable,
|
||||||
|
required this.isGameRunning,
|
||||||
|
required this.accountUsername,
|
||||||
required this.statusText,
|
required this.statusText,
|
||||||
required this.currentPath,
|
required this.currentPath,
|
||||||
required this.errorMessage,
|
required this.errorMessage,
|
||||||
@@ -50,6 +56,7 @@ final class HomeScreenModel {
|
|||||||
required this.newsItems,
|
required this.newsItems,
|
||||||
required this.isLoadingNews,
|
required this.isLoadingNews,
|
||||||
required this.newsErrorMessage,
|
required this.newsErrorMessage,
|
||||||
|
required this.realm,
|
||||||
required this.syncStage,
|
required this.syncStage,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,6 +66,8 @@ final class HomeScreenModel {
|
|||||||
phase = HomeScreenPhase.idle,
|
phase = HomeScreenPhase.idle,
|
||||||
isAuthenticated = false,
|
isAuthenticated = false,
|
||||||
hasClientExecutable = false,
|
hasClientExecutable = false,
|
||||||
|
isGameRunning = false,
|
||||||
|
accountUsername = null,
|
||||||
statusText = 'Загрузка...',
|
statusText = 'Загрузка...',
|
||||||
currentPath = null,
|
currentPath = null,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
@@ -69,6 +78,7 @@ final class HomeScreenModel {
|
|||||||
newsItems = const <LauncherNewsItem>[],
|
newsItems = const <LauncherNewsItem>[],
|
||||||
isLoadingNews = true,
|
isLoadingNews = true,
|
||||||
newsErrorMessage = null,
|
newsErrorMessage = null,
|
||||||
|
realm = null,
|
||||||
syncStage = null;
|
syncStage = null;
|
||||||
|
|
||||||
bool get isBusy =>
|
bool get isBusy =>
|
||||||
@@ -77,7 +87,7 @@ final class HomeScreenModel {
|
|||||||
|
|
||||||
bool get isSyncing => phase == HomeScreenPhase.syncing;
|
bool get isSyncing => phase == HomeScreenPhase.syncing;
|
||||||
|
|
||||||
bool get canPlay => hasClientExecutable && !isBusy;
|
bool get canPlay => hasClientExecutable && !isBusy && !isGameRunning;
|
||||||
|
|
||||||
HomeScreenModel copyWith({
|
HomeScreenModel copyWith({
|
||||||
DownloadProgress? progress,
|
DownloadProgress? progress,
|
||||||
@@ -85,6 +95,8 @@ final class HomeScreenModel {
|
|||||||
HomeScreenPhase? phase,
|
HomeScreenPhase? phase,
|
||||||
bool? isAuthenticated,
|
bool? isAuthenticated,
|
||||||
bool? hasClientExecutable,
|
bool? hasClientExecutable,
|
||||||
|
bool? isGameRunning,
|
||||||
|
Object? accountUsername = _sentinel,
|
||||||
String? statusText,
|
String? statusText,
|
||||||
Object? currentPath = _sentinel,
|
Object? currentPath = _sentinel,
|
||||||
Object? errorMessage = _sentinel,
|
Object? errorMessage = _sentinel,
|
||||||
@@ -95,6 +107,7 @@ final class HomeScreenModel {
|
|||||||
List<LauncherNewsItem>? newsItems,
|
List<LauncherNewsItem>? newsItems,
|
||||||
bool? isLoadingNews,
|
bool? isLoadingNews,
|
||||||
Object? newsErrorMessage = _sentinel,
|
Object? newsErrorMessage = _sentinel,
|
||||||
|
Object? realm = _sentinel,
|
||||||
Object? syncStage = _sentinel,
|
Object? syncStage = _sentinel,
|
||||||
}) {
|
}) {
|
||||||
return HomeScreenModel(
|
return HomeScreenModel(
|
||||||
@@ -105,6 +118,10 @@ final class HomeScreenModel {
|
|||||||
phase: phase ?? this.phase,
|
phase: phase ?? this.phase,
|
||||||
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
|
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
|
||||||
hasClientExecutable: hasClientExecutable ?? this.hasClientExecutable,
|
hasClientExecutable: hasClientExecutable ?? this.hasClientExecutable,
|
||||||
|
isGameRunning: isGameRunning ?? this.isGameRunning,
|
||||||
|
accountUsername: accountUsername == _sentinel
|
||||||
|
? this.accountUsername
|
||||||
|
: accountUsername as String?,
|
||||||
statusText: statusText ?? this.statusText,
|
statusText: statusText ?? this.statusText,
|
||||||
currentPath: currentPath == _sentinel
|
currentPath: currentPath == _sentinel
|
||||||
? this.currentPath
|
? this.currentPath
|
||||||
@@ -125,6 +142,7 @@ final class HomeScreenModel {
|
|||||||
newsErrorMessage: newsErrorMessage == _sentinel
|
newsErrorMessage: newsErrorMessage == _sentinel
|
||||||
? this.newsErrorMessage
|
? this.newsErrorMessage
|
||||||
: newsErrorMessage as String?,
|
: newsErrorMessage as String?,
|
||||||
|
realm: realm == _sentinel ? this.realm : realm as LauncherRealm?,
|
||||||
syncStage: syncStage == _sentinel
|
syncStage: syncStage == _sentinel
|
||||||
? this.syncStage
|
? this.syncStage
|
||||||
: syncStage as ClientSyncStage?,
|
: 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/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_bloc.dart';
|
||||||
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_model.dart';
|
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_model.dart';
|
||||||
import 'package:moonwell_launcher/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:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
class HomeScreenContent extends StatelessWidget {
|
class HomeScreenContent extends StatelessWidget {
|
||||||
@@ -17,14 +19,16 @@ class HomeScreenContent extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final model = context.watch<HomeScreenBloc>().state.model;
|
final model = context.watch<HomeScreenBloc>().state.model;
|
||||||
return MwLauncherShell(
|
return MwLauncherShell(
|
||||||
news: _buildNews(model),
|
news: _buildNews(context, model),
|
||||||
syncActions: _buildSyncActions(context, model),
|
syncActions: _buildSyncActions(context, model),
|
||||||
statusBar: _buildStatusBar(model),
|
statusBar: _buildStatusBar(model),
|
||||||
account: MwAccountAffordance(
|
account: MwAccountAffordance(
|
||||||
onSettings: () => _showSettings(context, model),
|
username: model.accountUsername,
|
||||||
onLogout: () =>
|
onLogout: () =>
|
||||||
context.read<HomeScreenBloc>().add(HomeScreenLogoutRequested()),
|
context.read<HomeScreenBloc>().add(HomeScreenLogoutRequested()),
|
||||||
),
|
),
|
||||||
|
onSettings: () => _showSettings(context, model),
|
||||||
|
username: model.accountUsername,
|
||||||
onDrag: windowManager.startDragging,
|
onDrag: windowManager.startDragging,
|
||||||
onDoubleTap: () => unawaited(_toggleMaximized()),
|
onDoubleTap: () => unawaited(_toggleMaximized()),
|
||||||
onMinimize: windowManager.minimize,
|
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
|
final state = model.isLoadingNews
|
||||||
? MwNewsListState.loading
|
? MwNewsListState.loading
|
||||||
: model.newsItems.isNotEmpty
|
: model.newsItems.isNotEmpty
|
||||||
@@ -51,11 +55,40 @@ class HomeScreenContent extends StatelessWidget {
|
|||||||
body: item.body,
|
body: item.body,
|
||||||
createdAt: item.createdAt,
|
createdAt: item.createdAt,
|
||||||
imageUrl: item.imageUrl,
|
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) {
|
Widget _buildSyncActions(BuildContext context, HomeScreenModel model) {
|
||||||
final presentation = _syncPresentation(model);
|
final presentation = _syncPresentation(model);
|
||||||
final progress = model.progress.total > 0
|
final progress = model.progress.total > 0
|
||||||
@@ -67,6 +100,9 @@ class HomeScreenContent extends StatelessWidget {
|
|||||||
currentPath: model.currentPath,
|
currentPath: model.currentPath,
|
||||||
error: model.errorMessage,
|
error: model.errorMessage,
|
||||||
progress: progress,
|
progress: progress,
|
||||||
|
realmName: model.realm?.name,
|
||||||
|
realmOnline: model.realm?.online,
|
||||||
|
realmGameBuild: model.realm?.gameBuild,
|
||||||
onPrimary: () {
|
onPrimary: () {
|
||||||
final bloc = context.read<HomeScreenBloc>();
|
final bloc = context.read<HomeScreenBloc>();
|
||||||
if (model.phase == HomeScreenPhase.readyToPlay && model.canPlay) {
|
if (model.phase == HomeScreenPhase.readyToPlay && model.canPlay) {
|
||||||
@@ -89,7 +125,7 @@ class HomeScreenContent extends StatelessWidget {
|
|||||||
return MwSyncPresentation.failure;
|
return MwSyncPresentation.failure;
|
||||||
}
|
}
|
||||||
if (model.phase == HomeScreenPhase.readyToPlay) {
|
if (model.phase == HomeScreenPhase.readyToPlay) {
|
||||||
return model.statusText.startsWith('Игра запущена')
|
return model.isGameRunning
|
||||||
? MwSyncPresentation.launched
|
? MwSyncPresentation.launched
|
||||||
: MwSyncPresentation.ready;
|
: MwSyncPresentation.ready;
|
||||||
}
|
}
|
||||||
@@ -125,35 +161,23 @@ class HomeScreenContent extends StatelessWidget {
|
|||||||
HomeScreenModel model,
|
HomeScreenModel model,
|
||||||
) async {
|
) async {
|
||||||
final bloc = context.read<HomeScreenBloc>();
|
final bloc = context.read<HomeScreenBloc>();
|
||||||
final themeController = LauncherThemeScope.of(context);
|
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => Dialog(
|
builder: (dialogContext) => Dialog(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 460, maxHeight: 620),
|
constraints: const BoxConstraints(maxWidth: 460, maxHeight: 620),
|
||||||
child: AnimatedBuilder(
|
child: SingleChildScrollView(
|
||||||
animation: themeController,
|
child: MwSettingsPanel(
|
||||||
builder: (context, _) => SingleChildScrollView(
|
installationPath: model.outputPath?.toFilePath(),
|
||||||
child: MwSettingsPanel(
|
syncActive: model.isSyncing || model.isGameRunning,
|
||||||
installationPath: model.outputPath?.toFilePath(),
|
onChooseDirectory: () {
|
||||||
themeVariant: themeController.variant,
|
Navigator.of(dialogContext).pop();
|
||||||
syncActive: model.isSyncing,
|
bloc.add(HomeScreenOutputDirRequested());
|
||||||
onChooseDirectory: () {
|
},
|
||||||
Navigator.of(dialogContext).pop();
|
onVerify: () {
|
||||||
bloc.add(HomeScreenOutputDirRequested());
|
Navigator.of(dialogContext).pop();
|
||||||
},
|
bloc.add(HomeScreenSyncRequested());
|
||||||
onThemeChanged: (variant) {
|
},
|
||||||
unawaited(themeController.setVariant(variant));
|
|
||||||
},
|
|
||||||
onVerify: () {
|
|
||||||
Navigator.of(dialogContext).pop();
|
|
||||||
bloc.add(HomeScreenSyncRequested());
|
|
||||||
},
|
|
||||||
onLogout: () {
|
|
||||||
Navigator.of(dialogContext).pop();
|
|
||||||
bloc.add(HomeScreenLogoutRequested());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -18,17 +18,30 @@ class LoginScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _LoginScreenState extends State<LoginScreen> {
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
final _usernameController = TextEditingController();
|
final _usernameController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
|
final _passwordConfirmationController = TextEditingController();
|
||||||
|
final _inviteCodeController = TextEditingController();
|
||||||
|
MwAuthenticationMode _mode = MwAuthenticationMode.login;
|
||||||
|
bool _termsAccepted = false;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
String? _success;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_usernameController.dispose();
|
_usernameController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
|
_passwordConfirmationController.dispose();
|
||||||
|
_inviteCodeController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() {
|
||||||
|
return _mode == MwAuthenticationMode.login ? _login() : _register();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _login() async {
|
Future<void> _login() async {
|
||||||
final username = _usernameController.text.trim();
|
final username = _usernameController.text.trim();
|
||||||
final password = _passwordController.text.trim();
|
final password = _passwordController.text.trim();
|
||||||
@@ -41,6 +54,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
|
_success = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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) {
|
String _formatError(Object error) {
|
||||||
final raw = error.toString();
|
final raw = error.toString();
|
||||||
if (raw.startsWith('Exception: ')) return raw.substring(11);
|
if (raw.startsWith('Exception: ')) return raw.substring(11);
|
||||||
@@ -82,10 +179,18 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
return MwLoginShell(
|
return MwLoginShell(
|
||||||
form: MwAuthenticationForm(
|
form: MwAuthenticationForm(
|
||||||
usernameController: _usernameController,
|
usernameController: _usernameController,
|
||||||
|
emailController: _emailController,
|
||||||
passwordController: _passwordController,
|
passwordController: _passwordController,
|
||||||
|
passwordConfirmationController: _passwordConfirmationController,
|
||||||
|
inviteCodeController: _inviteCodeController,
|
||||||
|
mode: _mode,
|
||||||
|
onModeChanged: _setMode,
|
||||||
|
termsAccepted: _termsAccepted,
|
||||||
|
onTermsChanged: (value) => setState(() => _termsAccepted = value),
|
||||||
loading: _isLoading,
|
loading: _isLoading,
|
||||||
error: _error,
|
error: _error,
|
||||||
onSubmit: _login,
|
success: _success,
|
||||||
|
onSubmit: _submit,
|
||||||
),
|
),
|
||||||
onDrag: windowManager.startDragging,
|
onDrag: windowManager.startDragging,
|
||||||
onDoubleTap: () => unawaited(_toggleMaximized()),
|
onDoubleTap: () => unawaited(_toggleMaximized()),
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ abstract final class MoonWellTheme {
|
|||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: surfaceHigh,
|
fillColor: surfaceHigh,
|
||||||
|
hintStyle: TextStyle(color: foreground.withAlpha(105)),
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 14,
|
vertical: 14,
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ class ClientSyncUseCase
|
|||||||
ClientSyncStatus(
|
ClientSyncStatus(
|
||||||
stage: ClientSyncStage.completed,
|
stage: ClientSyncStage.completed,
|
||||||
progress: _fullProgress(manifest),
|
progress: _fullProgress(manifest),
|
||||||
message: 'Client is up to date.',
|
message: 'Клиент обновлён.',
|
||||||
currentPath: null,
|
currentPath: null,
|
||||||
localBuildHash: localSnapshot.buildHash,
|
localBuildHash: localSnapshot.buildHash,
|
||||||
remoteBuildHash: remoteBuildHash,
|
remoteBuildHash: remoteBuildHash,
|
||||||
@@ -225,7 +225,7 @@ class ClientSyncUseCase
|
|||||||
ClientSyncStatus(
|
ClientSyncStatus(
|
||||||
stage: ClientSyncStage.completed,
|
stage: ClientSyncStage.completed,
|
||||||
progress: _fullProgress(manifest),
|
progress: _fullProgress(manifest),
|
||||||
message: 'Client is ready to play.',
|
message: 'Клиент готов к игре.',
|
||||||
currentPath: null,
|
currentPath: null,
|
||||||
localBuildHash: verifiedSnapshot.buildHash,
|
localBuildHash: verifiedSnapshot.buildHash,
|
||||||
remoteBuildHash: remoteBuildHash,
|
remoteBuildHash: remoteBuildHash,
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ typedef InstallationScanProgressCallback =
|
|||||||
int totalFiles,
|
int totalFiles,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final class GameProcessHandle {
|
||||||
|
final int pid;
|
||||||
|
final Future<int> exitCode;
|
||||||
|
|
||||||
|
const GameProcessHandle({required this.pid, required this.exitCode});
|
||||||
|
}
|
||||||
|
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class GameInstallationService {
|
class GameInstallationService {
|
||||||
Future<ClientInstallationSnapshot> scanInstallation(
|
Future<ClientInstallationSnapshot> scanInstallation(
|
||||||
@@ -209,7 +216,7 @@ class GameInstallationService {
|
|||||||
await cacheDirectory.create(recursive: true);
|
await cacheDirectory.create(recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> launchGame(String installationDir) async {
|
Future<GameProcessHandle> launchGame(String installationDir) async {
|
||||||
final executablePath = getExecutablePath(installationDir);
|
final executablePath = getExecutablePath(installationDir);
|
||||||
final executable = File(executablePath);
|
final executable = File(executablePath);
|
||||||
|
|
||||||
@@ -219,12 +226,16 @@ class GameInstallationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Process.start(
|
final process = await Process.start(
|
||||||
executablePath,
|
executablePath,
|
||||||
const <String>[],
|
const <String>[],
|
||||||
workingDirectory: installationDir,
|
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 {
|
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/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.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.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_exception.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.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/launcher/domain/entities/launcher_session.dart';
|
||||||
|
|
||||||
@lazySingleton
|
@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 {
|
Future<ClientManifest> fetchManifest(String accessToken) async {
|
||||||
final manifestUri = _resolveUri('api/launcher/manifest');
|
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 flutter_secure_storage_darwin
|
||||||
import screen_retriever_macos
|
import screen_retriever_macos
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
import url_launcher_macos
|
||||||
import window_manager
|
import window_manager
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
@@ -18,5 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
+407
-1
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.0.0",
|
"openapi": "3.0.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Moonwell Launcher API",
|
"title": "Moonwell Launcher API",
|
||||||
"description": "API для лаунчера WoW-клиента Moonwell. Авторизация, получение манифеста файлов и скачивание обновлений.",
|
"description": "API для лаунчера WoW-клиента Moonwell. Авторизация, новости, реалмы и их статусы, получение манифеста файлов и скачивание обновлений.",
|
||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"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": {
|
"components": {
|
||||||
@@ -283,6 +679,12 @@
|
|||||||
"description": "Токен, полученный через POST /api/launcher/login",
|
"description": "Токен, полученный через POST /api/launcher/login",
|
||||||
"bearerFormat": "JWT",
|
"bearerFormat": "JWT",
|
||||||
"scheme": "bearer"
|
"scheme": "bearer"
|
||||||
|
},
|
||||||
|
"launcherAuth": {
|
||||||
|
"type": "apiKey",
|
||||||
|
"description": "Сервисный ключ из LAUNCHER_AUTH_KEY для запросов лаунчера к служебным endpoint-ам.",
|
||||||
|
"name": "Auth",
|
||||||
|
"in": "header"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -294,6 +696,10 @@
|
|||||||
{
|
{
|
||||||
"name": "Launcher",
|
"name": "Launcher",
|
||||||
"description": "Launcher"
|
"description": "Launcher"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Launcher Service",
|
||||||
|
"description": "Launcher Service"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+74
-10
@@ -141,10 +141,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.0"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -540,26 +540,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.17"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.0"
|
version: "0.11.1"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -905,10 +905,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.7"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -917,6 +917,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ">=3.10.0"
|
sdk: ">=3.10.0"
|
||||||
@@ -46,6 +46,7 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
window_manager: ^0.5.1
|
window_manager: ^0.5.1
|
||||||
auto_updater: 1.0.0
|
auto_updater: 1.0.0
|
||||||
|
url_launcher: ^6.3.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -73,6 +74,8 @@ flutter:
|
|||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
assets:
|
assets:
|
||||||
- assets/background.png
|
- assets/background.png
|
||||||
|
- assets/launcher_key_art.png
|
||||||
|
- assets/login_key_art.png
|
||||||
- assets/logo.png
|
- assets/logo.png
|
||||||
- assets/fonts/Cinzel-OFL.txt
|
- assets/fonts/Cinzel-OFL.txt
|
||||||
- assets/fonts/CormorantGaramond-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 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
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/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/application/client_sync_use_case.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.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/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_installation_snapshot.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.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/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_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/launcher/domain/entities/launcher_session.dart';
|
||||||
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
|
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
|
||||||
|
|
||||||
@@ -29,6 +32,19 @@ void main() {
|
|||||||
createdAt: null,
|
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(),
|
preferencesRepository: _FakePreferencesRepository(),
|
||||||
session: LauncherSession(
|
session: LauncherSession(
|
||||||
@@ -47,12 +63,18 @@ void main() {
|
|||||||
expect(bloc.state.model.newsErrorMessage, isNull);
|
expect(bloc.state.model.newsErrorMessage, isNull);
|
||||||
expect(bloc.state.model.newsItems, hasLength(1));
|
expect(bloc.state.model.newsItems, hasLength(1));
|
||||||
expect(bloc.state.model.newsItems.first.title, 'Update 1.2');
|
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();
|
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 syncUseCase = _CapturingClientSyncUseCase();
|
||||||
|
final manifest = ClientManifest.fromJson(<String, Object?>{
|
||||||
|
'files': <Map<String, Object?>>[],
|
||||||
|
});
|
||||||
final bloc = HomeScreenBloc(
|
final bloc = HomeScreenBloc(
|
||||||
clientSyncUseCase: syncUseCase,
|
clientSyncUseCase: syncUseCase,
|
||||||
gameInstallationService: _FakeGameInstallationService(),
|
gameInstallationService: _FakeGameInstallationService(),
|
||||||
@@ -65,17 +87,99 @@ void main() {
|
|||||||
tokenType: 'Bearer',
|
tokenType: 'Bearer',
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
),
|
),
|
||||||
manifest: ClientManifest.fromJson(<String, Object?>{
|
manifest: manifest,
|
||||||
'files': <Map<String, Object?>>[],
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await pumpEventQueue(times: 20);
|
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);
|
await pumpEventQueue(times: 20);
|
||||||
|
|
||||||
expect(syncUseCase.lastInput, isNotNull);
|
expect(apiClient.manifestRequestCount, 1);
|
||||||
expect(syncUseCase.lastInput!.request.manifest, isNull);
|
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();
|
await bloc.close();
|
||||||
});
|
});
|
||||||
@@ -96,23 +200,59 @@ class _IdleClientSyncUseCase extends ClientSyncUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CapturingClientSyncUseCase extends _IdleClientSyncUseCase {
|
class _CapturingClientSyncUseCase extends _IdleClientSyncUseCase {
|
||||||
ClientSyncUseCaseInput? lastInput;
|
final List<ClientSyncUseCaseInput> inputs = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<ClientSyncStatus> call(ClientSyncUseCaseInput input) {
|
Stream<ClientSyncStatus> call(ClientSyncUseCaseInput input) {
|
||||||
lastInput = input;
|
inputs.add(input);
|
||||||
return const Stream<ClientSyncStatus>.empty();
|
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 {
|
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 List<LauncherNewsItem> newsItems;
|
||||||
|
final ClientManifest? manifest;
|
||||||
|
final List<LauncherRealm> realms;
|
||||||
|
final LauncherAccount account;
|
||||||
|
int manifestRequestCount = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<LauncherNewsItem>> fetchNews(String accessToken) async =>
|
Future<List<LauncherNewsItem>> fetchNews(String accessToken) async =>
|
||||||
newsItems;
|
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 {
|
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 {
|
class _FakePreferencesRepository implements PreferencesRepository {
|
||||||
_FakePreferencesRepository({this.outputDir});
|
_FakePreferencesRepository({this.outputDir});
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ void main() {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
expect(statuses.last.stage, ClientSyncStage.completed);
|
expect(statuses.last.stage, ClientSyncStage.completed);
|
||||||
expect(statuses.last.message, 'Client is up to date.');
|
expect(statuses.last.message, 'Клиент обновлён.');
|
||||||
expect(api.downloadedPaths, isEmpty);
|
expect(api.downloadedPaths, isEmpty);
|
||||||
expect(installationService.deletedRelativeFiles, isEmpty);
|
expect(installationService.deletedRelativeFiles, isEmpty);
|
||||||
expect(logger.errorMessages, isEmpty);
|
expect(logger.errorMessages, isEmpty);
|
||||||
@@ -139,7 +139,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(installationService.deletedRelativeFiles, ['legacy.txt']);
|
expect(installationService.deletedRelativeFiles, ['legacy.txt']);
|
||||||
expect(api.downloadedPaths, ['Wow.exe', 'Data/common.MPQ']);
|
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);
|
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:flutter/material.dart';
|
||||||
import 'package:moonwell_launcher/app/design_system/mw_launcher_components.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/widgetbook.dart';
|
||||||
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
|
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]')
|
@widgetbook.UseCase(name: 'full', type: MwGameRail, path: '[Launcher]')
|
||||||
Widget buildMwGameRailFullUseCase(BuildContext context) {
|
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]')
|
@widgetbook.UseCase(name: 'compact', type: MwGameRail, path: '[Launcher]')
|
||||||
@@ -39,7 +42,7 @@ Widget buildMwGameRailCompactUseCase(BuildContext context) {
|
|||||||
return const SizedBox(
|
return const SizedBox(
|
||||||
width: 72,
|
width: 72,
|
||||||
height: 560,
|
height: 560,
|
||||||
child: MwGameRail(compact: true),
|
child: MwGameRail(compact: true, username: 'Aranthel'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,10 +194,7 @@ Widget buildMwLauncherStatusBarMetadataUseCase(BuildContext context) {
|
|||||||
path: '[Launcher]',
|
path: '[Launcher]',
|
||||||
)
|
)
|
||||||
Widget buildMwAccountAffordanceUseCase(BuildContext context) {
|
Widget buildMwAccountAffordanceUseCase(BuildContext context) {
|
||||||
return MwAccountAffordance(
|
return MwAccountAffordance(onLogout: () => debugPrint('Logout requested'));
|
||||||
onSettings: () => debugPrint('Settings requested'),
|
|
||||||
onLogout: () => debugPrint('Logout requested'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@widgetbook.UseCase(
|
@widgetbook.UseCase(
|
||||||
@@ -203,12 +203,6 @@ Widget buildMwAccountAffordanceUseCase(BuildContext context) {
|
|||||||
path: '[Launcher]',
|
path: '[Launcher]',
|
||||||
)
|
)
|
||||||
Widget buildMwSettingsPanelInteractiveUseCase(BuildContext context) {
|
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(
|
return SizedBox(
|
||||||
width: 420,
|
width: 420,
|
||||||
child: MwSettingsPanel(
|
child: MwSettingsPanel(
|
||||||
@@ -216,15 +210,12 @@ Widget buildMwSettingsPanelInteractiveUseCase(BuildContext context) {
|
|||||||
label: 'installationPath',
|
label: 'installationPath',
|
||||||
initialValue: r'C:\Games\MoonWell',
|
initialValue: r'C:\Games\MoonWell',
|
||||||
),
|
),
|
||||||
themeVariant: variant,
|
|
||||||
syncActive: context.knobs.boolean(
|
syncActive: context.knobs.boolean(
|
||||||
label: 'syncActive',
|
label: 'syncActive',
|
||||||
initialValue: false,
|
initialValue: false,
|
||||||
),
|
),
|
||||||
onChooseDirectory: () => debugPrint('Directory selection requested'),
|
onChooseDirectory: () => debugPrint('Directory selection requested'),
|
||||||
onThemeChanged: (value) => debugPrint('Theme changed: ${value.name}'),
|
|
||||||
onVerify: () => debugPrint('Manual verification requested'),
|
onVerify: () => debugPrint('Manual verification requested'),
|
||||||
onLogout: () => debugPrint('Logout requested'),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,9 +147,11 @@ MwLauncherShell _launcherShell({
|
|||||||
buildHash: 'a93fd1c2',
|
buildHash: 'a93fd1c2',
|
||||||
),
|
),
|
||||||
account: MwAccountAffordance(
|
account: MwAccountAffordance(
|
||||||
onSettings: () => debugPrint('Settings requested'),
|
username: 'Aranthel',
|
||||||
onLogout: () => debugPrint('Logout requested'),
|
onLogout: () => debugPrint('Logout requested'),
|
||||||
),
|
),
|
||||||
|
onSettings: () => debugPrint('Settings requested'),
|
||||||
|
username: 'Aranthel',
|
||||||
onDrag: _windowCallback('drag'),
|
onDrag: _windowCallback('drag'),
|
||||||
onDoubleTap: _windowCallback('double_tap'),
|
onDoubleTap: _windowCallback('double_tap'),
|
||||||
onMinimize: _windowCallback('minimize'),
|
onMinimize: _windowCallback('minimize'),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <auto_updater_windows/auto_updater_windows_plugin_c_api.h>
|
#include <auto_updater_windows/auto_updater_windows_plugin_c_api.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.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>
|
#include <window_manager/window_manager_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
@@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
|
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
|
||||||
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
WindowManagerPluginRegisterWithRegistrar(
|
WindowManagerPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
|
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
auto_updater_windows
|
auto_updater_windows
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
screen_retriever_windows
|
screen_retriever_windows
|
||||||
|
url_launcher_windows
|
||||||
window_manager
|
window_manager
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
-----BEGIN PUBLIC KEY-----
|
-----BEGIN PUBLIC KEY-----
|
||||||
MIIDQjCCAjUGByqGSM44BAEwggIoAoIBAQC5rT00uqOtUBNAtY/Kzep4xFXX34Xh
|
MIIExDCCAzYGByqGSM44BAEwggMpAoIBgQDpng6PomcvdTHaLxSnJJ7I4uHrrr6p
|
||||||
ArYMNMsc0lSCm7WzwklN1GTqKwp18gUxU1WX4YepePBwb4Mtm22VdYW76xKUMXpP
|
yjUrf3FkBOz9833z8KqD/xnFRHOuuB/LxVHFfAexCT/QlvA0Fbdh5A+zhM2mubdo
|
||||||
fZ/d2e+l2jwo/urixdQ9vdGrYTkPJLS9Mn6q/7/XQnIx/MTroOcYNh1QJj3Gynxy
|
d83YlaPdJdq9imhUhqNOOIssY7yD04vHU5O2yHkX9sMIk0RWI/EPlua5xPKcGr5p
|
||||||
y2C95Ty4yaxpjz9BP4TzT8XSvAy4ItdE0Xv2DqvruAP8VLR7HLAYMZFxvCuC65EC
|
yqNEIH32fOjSQ5APKMEY8oRNePEfJAqklNr4AKg35pitaeJWfmmMDUxL0ElgMyTB
|
||||||
3nLIRByL8s7CGq8w2Wn7x8WKI/PQ/RvngMoc0xrK58qV+I2hFefXSYG4T7/aDvOb
|
VBQOtNv9Q312lUSzVKaud6aN96/Brkm4pxiZfOvPtk0Ch3TDNyllmyHGpiJ3tFuC
|
||||||
MTnIHlBZcgSEGCFDhtRqStKcBVW+ybk9SRpePU8rWL6QTtZTaBRlGlyRAh0A9A66
|
vX1fNDjk4NNAlEIriZvkE1h2L8I7YyAv8DzpxGya/s0/okmMh1bylnyA2ekA2Spx
|
||||||
ZMG2sKJqs2w6X+dUCXzeNUMSrYpQit6UjwKCAQBJShETJlN90ZvrtYSOUnTrXKlH
|
dVlvHBdRdC5cBaujwANGaY1IjT1lBpz41bpilKaXzp9JWKHKmNBYaWhNbZXjpzaA
|
||||||
rka5s6d1xFUNyqVNlI84oRC2gfw+eVAzbjtwFXv342uSHgC+kwQwK5UClxBTDxcm
|
ZPvhQaQ7qrSWn4F9lUHa00t3sGJxCv/BjCs9uclMsUuNuKck/NUM1LpHjWLbRzUD
|
||||||
YK4yC3288a4BoCf76SkLcUOdEp2/Ef1MjMSWKorcX9SAKY98RihrRXoHLrmDN3vk
|
RLlpALEKMjOf0blDf9Pm6hiteLFDnXxbzaMCHQDP4E8TpSCJnn3R0RDBdwpKwt2T
|
||||||
/SVqgLv1Auu5X7yHMO2IDWvI29Sh6W13i2joXFt2O5ufsMY1k6sQtnT6R7sLphVw
|
VlCPnlTHUjuJAoIBgQC6iQTIGQH/1+LT+QjCOIUuImRC8jXLbvJ9opO3GXNOelWz
|
||||||
ifE6ulDwRNjSkPMFCrKyKpnqXmC1TvtAdkSd0DsWEfQuqp3878PZoVTJzHxrl1Y0
|
LB3rcaFUad1kNoAVbN2ZV3p7lDxnjXNRzxMiuXthGyzj5ckvZ4qval2OevxyQSZf
|
||||||
3M7yrbVj5OojdGA9kX0zelrNCIPuH+2vmfeFis8y5XFFEOxc9yWezFYJ3TfEA4IB
|
k9Ux9ETW9dn4uBLQ2Z7oHR2hioB/xy+xVG8WlNsHxQl3duiMZodrLGBDp9xCEUgK
|
||||||
BQACggEAfoxCOU4ofKti7T7QVV3d5Oh6fIi2eTLG4utP6Fvtb6AktF6OPJWeDmDe
|
3X6A9fN49PYDu3FRwutYswLOa1XodWacpfebGKWEAB3/ftCA7v+y30nsguYyNfo1
|
||||||
g3ffLkKyx8/uA+KuZJO3/t/pHvzUvWNmc487bFZYd0dhlObQEu6Qg5uqDVWG4nqy
|
cAlhH/m62oljUe3asQbjiLgQLqRgUagERrQqp30F+xCRsJUp6ByYQ1zKrs8EXzZN
|
||||||
K1zNmhVO97SqzBjUklyaxQre65w3XI4a1XSk8RAkcMzu5rze4P9BodEChaG/kEih
|
EZ7pu4c4c29ynrHpPbAnam/Dnppyjay/J70rQ0nEpyULtyfNp3yIni2AvQhi57Bu
|
||||||
qCJZivBjhMjaKi5cmE/S9G0zMrdTf152V8WaN0EjbFThC1izxk2j+76t+ALAe1Tt
|
QukE1sa+4gUciPBAkyBzI9wc6Qk3VJiD0mJ7xzMoxJjKBLSVxxpYTvnh+H1x4YFk
|
||||||
I1bY9RT8pzjdfRwWHja0cooPILCWL82pcolOAvrNzhkbR9sIcw5t9F517EQch4VD
|
fgoePjcz79nrw8n8DAtsM0UbTH5u0WFCSbXx6X/0uj1V7fSS+9n0DfzWQ9xaovrH
|
||||||
0W9SMpZl5uKcw3RS0oiFWyyKNWavvQ==
|
r5V9xhv9Fup4WzC93RQDggGGAAKCAYEA0VaaXkE8/MCGF6oVBOmHZ0kiPtu51wIZ
|
||||||
|
Z/0MBR4FH9+X4aatTHcfY5W8b2PRObWSLbwviRHpaOcz0KxOFA4030ZoQcV7h+RC
|
||||||
|
rPnSgrgKSDK3myvtWGpZn4chIWd0FnGdpCFDdLDkukyqa8B94C/tjlPT2MyY8PiS
|
||||||
|
wYKAzMEU4TqSkHrOXlNtSo398eH9zOzMXqHe4UWCtO4ZL6b7SaDwR4FaHStp/VRK
|
||||||
|
bTYkiVPnVF25Ye+txpasJ1DdFXXnQjng0pjlA1CF7ieNmjqPXaKeCu7d+e2Bz3U1
|
||||||
|
VPOzmCYfqynAJ5IJEtsnfpPeKlLHtGtNlGctZXSotkpgY4ywWldJA9UtO1lh7gBy
|
||||||
|
3wykAdiao/O/uQh4cxMes2TqgBCJoBr41OCiir1NiS04m0cJpyVBBAAylvGFvlaU
|
||||||
|
cWbkSan2aRSarX68nmOwJx4jQYC0QQCe8J3wsEhYY3HgnczYMkmRP0QDj3xVMjRS
|
||||||
|
nnS8mysVTmqabcs8SjfThrWTpzCbBIgJ
|
||||||
-----END PUBLIC KEY-----
|
-----END PUBLIC KEY-----
|
||||||
|
|||||||
Reference in New Issue
Block a user