Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd189fe48f | |||
| 25e7ad156b | |||
| b64dffca9a | |||
| 95efa2bc31 | |||
| e98bcf407c | |||
| f395d2515d | |||
| 3b01afeeb2 | |||
| b7e6a4b393 | |||
| 33659f6c05 | |||
| bd1e464b5a | |||
| 3d43420676 | |||
| c4576c84bb | |||
| 0b6f7fc647 | |||
| 155922a3f1 | |||
| 3134ecad0f |
@@ -0,0 +1,7 @@
|
|||||||
|
AWS_ACCESS_KEY_ID=replace_with_access_key_id
|
||||||
|
AWS_SECRET_ACCESS_KEY=replace_with_secret_access_key
|
||||||
|
AWS_DEFAULT_REGION=ru-central1
|
||||||
|
AWS_BUCKET=warcraft-client
|
||||||
|
AWS_ENDPOINT=https://storage.yandexcloud.net
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||||
|
LAUNCHER_AUTH_KEY=replace_with_launcher_auth_key
|
||||||
@@ -49,3 +49,8 @@ app.*.map.json
|
|||||||
|
|
||||||
# Local launcher update signing material
|
# Local launcher update signing material
|
||||||
.moonwell_signing/
|
.moonwell_signing/
|
||||||
|
|
||||||
|
# Local production deployment credentials
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|||||||
Vendored
+3
-2
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"dart.flutterSdkPath": ".fvm/versions/stable"
|
"dart.flutterSdkPath": ".fvm/versions/stable",
|
||||||
}
|
"cmake.sourceDirectory": "C:/Users/sindo/moonwell_launcher/windows"
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,12 +4,23 @@ MoonWell Launcher is the desktop launcher for MoonWell, a private World of
|
|||||||
Warcraft: Wrath of the Lich King server focused on a player-friendly experience
|
Warcraft: Wrath of the Lich King server focused on a player-friendly experience
|
||||||
without excessive grind and with stronger social play.
|
without excessive grind and with stronger social play.
|
||||||
|
|
||||||
|
The Windows executable requests administrator privileges on startup so it can
|
||||||
|
synchronize game clients installed under protected directories such as
|
||||||
|
`Program Files`.
|
||||||
|
|
||||||
|
Launcher version labels are read from the packaged application metadata. Change
|
||||||
|
only the `version: X.Y.Z+N` value in `pubspec.yaml`; login, launcher status,
|
||||||
|
Windows metadata, installer packaging, and AppCast are synchronized from it.
|
||||||
|
|
||||||
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
|
||||||
- launching the game client
|
- displaying the live status of the first configured realm
|
||||||
|
- requesting a single-use game ticket and launching the game client without
|
||||||
|
putting credentials in command-line arguments
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -30,20 +41,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 +63,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 +72,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 +82,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 +97,30 @@ 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.
|
||||||
|
|
||||||
|
Local deploy credentials are read from the ignored root `.env`; use
|
||||||
|
`.env.example` as the safe template. Process and CI environment variables
|
||||||
|
override values from the file.
|
||||||
|
|
||||||
## 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 +132,17 @@ 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. request a short-lived game ticket and launch `Wow.exe` from the selected
|
||||||
|
client directory with authorization in its child environment
|
||||||
|
|
||||||
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 +155,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
|
||||||
|
|||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<?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 updates</description>
|
||||||
|
<language>ru</language>
|
||||||
|
<item>
|
||||||
|
<title>MoonWell Launcher 1.0.2</title>
|
||||||
|
<description>Исправлена работа с аддонами.</description>
|
||||||
|
<pubDate>Fri, 07 Aug 2026 12:08:41 +04:00</pubDate>
|
||||||
|
<enclosure url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe" sparkle:version="1.0.2+5" sparkle:shortVersionString="1.0.2" sparkle:os="windows" sparkle:dsaSignature="MD4CHQCtVFySsPfkxaSz+nf+vEovupcILFRGQMcEhrsNAh0Ar7GEhqEL2kan6wGXHZ0rfiQqYCzQJ30kXBsrvg==" length="19993275" 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 |
@@ -9,14 +9,19 @@ WinSparkle shows its native prompt only when a newer version is available.
|
|||||||
Missing configuration, network failures, and malformed feeds do not block the
|
Missing configuration, network failures, and malformed feeds do not block the
|
||||||
launcher. Periodic WinSparkle checks are disabled.
|
launcher. Periodic WinSparkle checks are disabled.
|
||||||
|
|
||||||
|
`MoonWell.exe` uses the Windows `requireAdministrator` execution level. Users
|
||||||
|
therefore receive a UAC prompt whenever the launcher starts. This is required
|
||||||
|
to synchronize game files installed in protected directories. The per-user
|
||||||
|
Inno Setup installer remains configured with `PrivilegesRequired=lowest`.
|
||||||
|
|
||||||
## Build Configuration
|
## Build Configuration
|
||||||
|
|
||||||
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
|
||||||
@@ -45,8 +50,11 @@ certificate would not establish trust on player machines.
|
|||||||
## Version and AppCast Contract
|
## Version and AppCast Contract
|
||||||
|
|
||||||
Increase the `X.Y.Z` version in `pubspec.yaml` for every launcher release. A
|
Increase the `X.Y.Z` version in `pubspec.yaml` for every launcher release. A
|
||||||
build-number-only change such as `1.0.0+2` is not sufficient because WinSparkle
|
build-number-only change such as `1.0.0+2` is not sufficient for a public
|
||||||
compares the Windows product version. Pass the same `X.Y.Z` value to Inno Setup:
|
release. Flutter writes the complete `X.Y.Z+N` value into the Windows
|
||||||
|
`ProductVersion`; `sparkle:version` must match that complete value exactly.
|
||||||
|
Use `sparkle:shortVersionString` for the human-readable `X.Y.Z` value. Pass
|
||||||
|
`X.Y.Z` without the build suffix to Inno Setup:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
ISCC.exe /DMyAppVersion=1.1.0 installer\moonwell_launcher.iss
|
ISCC.exe /DMyAppVersion=1.1.0 installer\moonwell_launcher.iss
|
||||||
@@ -67,11 +75,11 @@ 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+3"
|
||||||
|
sparkle:shortVersionString="1.1.0"
|
||||||
sparkle:os="windows"
|
sparkle:os="windows"
|
||||||
sparkle:dsaSignature="SIGNATURE_FROM_SIGN_UPDATE"
|
sparkle:dsaSignature="SIGNATURE_FROM_SIGN_UPDATE"
|
||||||
length="ACTUAL_INSTALLER_SIZE_IN_BYTES"
|
length="ACTUAL_INSTALLER_SIZE_IN_BYTES"
|
||||||
@@ -90,14 +98,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 +123,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,31 @@ 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`
|
||||||
|
8. `POST /api/launcher/game-ticket`
|
||||||
|
|
||||||
|
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 +55,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 +84,41 @@ 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.
|
||||||
|
|
||||||
|
Game authorization sequence:
|
||||||
|
|
||||||
|
1. Immediately before starting the game, the launcher calls
|
||||||
|
`POST /api/launcher/game-ticket` with bearer authentication and
|
||||||
|
`{ "client_build": 12340 }` in the JSON body.
|
||||||
|
2. The API returns `account`, a 16-character `A-Z0-9` single-use `ticket`, and
|
||||||
|
`expires_at`. The ticket must remain valid for at least five more seconds.
|
||||||
|
3. Issuing a ticket atomically revokes any previous unused game ticket for the
|
||||||
|
same account. Production lifetime must not exceed 60 seconds.
|
||||||
|
4. The launcher never persists or logs the ticket.
|
||||||
|
5. The auth server uses the ticket as the temporary SRP password and consumes
|
||||||
|
it atomically after a successful logon proof.
|
||||||
|
|
||||||
## Installation Directory Rules
|
## Installation Directory Rules
|
||||||
|
|
||||||
@@ -190,9 +244,18 @@ Pause is implemented as cooperative cancellation:
|
|||||||
|
|
||||||
When the user presses `Play`:
|
When the user presses `Play`:
|
||||||
|
|
||||||
1. launcher resolves `<install-root>/Wow.exe`
|
1. launcher removes every `SET accountName ...` line from
|
||||||
2. launcher clears `<install-root>/Cache`
|
`<install-root>/WTF/Config.wtf` when that file exists
|
||||||
3. launcher starts `Wow.exe` with working directory set to installation root
|
2. launcher resolves `<install-root>/Wow.exe`
|
||||||
|
3. launcher clears `<install-root>/Cache`
|
||||||
|
4. launcher requests a single-use game ticket
|
||||||
|
5. launcher starts `Wow.exe` with working directory set to installation root,
|
||||||
|
no authorization command-line arguments, and these child environment values:
|
||||||
|
- `MOONWELL_LAUNCH_ACCOUNT=<account>`
|
||||||
|
- `MOONWELL_LAUNCH_TICKET=<ticket>`
|
||||||
|
6. launcher retains a process handle; while the process is alive, it disables repeated game launches
|
||||||
|
and client synchronization
|
||||||
|
7. 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,12 +265,14 @@ 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
|
||||||
- missing downloaded temp files before verification
|
- missing downloaded temp files before verification
|
||||||
- final verification mismatch after update
|
- final verification mismatch after update
|
||||||
- missing `Wow.exe`
|
- missing `Wow.exe`
|
||||||
|
- invalid, expired, or unavailable game ticket
|
||||||
|
|
||||||
## Tested Invariants
|
## Tested Invariants
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
The PowerShell entry point calls `tool/upload_launcher_release.py` as a regular
|
||||||
|
Python file. Keep this helper beside the deployment script; it performs the S3
|
||||||
|
upload and verifies the uploaded object's byte length.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Local `.env`
|
||||||
|
|
||||||
|
For local releases, copy `.env.example` to `.env` and fill in the real values.
|
||||||
|
The repository ignores `.env` and every `.env.*` variant except the safe
|
||||||
|
`.env.example` template.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Copy-Item .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
The deploy script loads `.env` automatically. Variables already present in the
|
||||||
|
current process or CI environment take precedence over the file. Use
|
||||||
|
`-EnvFile C:\secure\moonwell.env` to select a different local file.
|
||||||
|
|
||||||
|
The following values are supported:
|
||||||
|
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
Production CI should load these values from the team secret manager instead of
|
||||||
|
creating `.env`. Never commit the real file or put its contents into logs.
|
||||||
|
|
||||||
|
## Prepare a Release
|
||||||
|
|
||||||
|
Increase both parts of the version in `pubspec.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: 1.0.2+3
|
||||||
|
```
|
||||||
|
|
||||||
|
`X.Y.Z` is the public launcher version. `N` is the Flutter build number.
|
||||||
|
Flutter places the complete `X.Y.Z+N` value in the Windows `ProductVersion`,
|
||||||
|
so the script writes that same complete value to `sparkle:version` and writes
|
||||||
|
`X.Y.Z` to `sparkle:shortVersionString`. Every production release must have an
|
||||||
|
`X.Y.Z` value greater than the release currently published in AppCast.
|
||||||
|
|
||||||
|
The UI reads `X.Y.Z` from the packaged application metadata through
|
||||||
|
`package_info_plus`. Version labels on the login and launcher screens therefore
|
||||||
|
update automatically after changing `pubspec.yaml`; do not add separate
|
||||||
|
hardcoded version strings.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -44,5 +44,11 @@ Source: "{#MyBuildDir}\*"; DestDir: "{app}"; Excludes: "moonwell_launcher.exe";
|
|||||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||||
|
|
||||||
|
[Registry]
|
||||||
|
Root: HKCU; Subkey: "Software\Classes\moonwell"; ValueType: string; ValueName: ""; ValueData: "URL:MoonWell Launcher Protocol"; Flags: uninsdeletekey
|
||||||
|
Root: HKCU; Subkey: "Software\Classes\moonwell"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""
|
||||||
|
Root: HKCU; Subkey: "Software\Classes\moonwell\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"
|
||||||
|
Root: HKCU; Subkey: "Software\Classes\moonwell\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""
|
||||||
|
|
||||||
[Run]
|
[Run]
|
||||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||||
|
|||||||
@@ -1,22 +1,41 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:moonwell_launcher/app/launcher_version.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 +47,8 @@ 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;
|
||||||
|
final launcherVersion = LauncherVersionScope.of(context);
|
||||||
return AutofillGroup(
|
return AutofillGroup(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -35,13 +56,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 +70,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 +84,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 +116,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 +136,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 +172,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,27 +244,27 @@ 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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
const Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'MOONWELL · V 1.0.0 · LOGIN.MOONWELL.GG',
|
'MOONWELL · V $launcherVersion · PLAY.MOON-WELL.ONLINE',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
color: Color(0x52ECE4D0),
|
color: Color(0x52ECE4D0),
|
||||||
fontSize: 8,
|
fontSize: 8,
|
||||||
@@ -187,30 +297,33 @@ 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 : Colors.transparent),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
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,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:moonwell_launcher/app/launcher_version.dart';
|
||||||
import 'package:moonwell_launcher/app/design_system/mw_primitives.dart';
|
import 'package:moonwell_launcher/app/design_system/mw_primitives.dart';
|
||||||
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ class MwGameTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!enabled)
|
if (!enabled && subtitle == null)
|
||||||
const Text(
|
const Text(
|
||||||
'SOON',
|
'SOON',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -121,13 +122,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 +166,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 +198,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 +216,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 +239,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 +256,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 +351,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 +365,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 +467,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 +478,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 +495,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 +529,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 +620,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 +681,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 {
|
||||||
@@ -675,121 +719,104 @@ class MwLauncherStatusBar extends StatelessWidget {
|
|||||||
final String? eta;
|
final String? eta;
|
||||||
final String? buildHash;
|
final String? buildHash;
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Container(
|
Widget build(BuildContext context) {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
final launcherVersion = LauncherVersionScope.of(context);
|
||||||
decoration: const BoxDecoration(
|
return Container(
|
||||||
color: Color(0xD904060E),
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
border: Border(top: BorderSide(color: _border)),
|
decoration: const BoxDecoration(
|
||||||
),
|
color: Color(0xD904060E),
|
||||||
child: DefaultTextStyle(
|
border: Border(top: BorderSide(color: _border)),
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'JetBrains Mono',
|
|
||||||
color: _muted,
|
|
||||||
fontSize: 8,
|
|
||||||
letterSpacing: .8,
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: DefaultTextStyle(
|
||||||
children: [
|
style: const TextStyle(
|
||||||
Expanded(
|
fontFamily: 'JetBrains Mono',
|
||||||
child: Text(status, maxLines: 1, overflow: TextOverflow.ellipsis),
|
color: _muted,
|
||||||
),
|
fontSize: 8,
|
||||||
if (fileCount != null) Text(fileCount!),
|
letterSpacing: .8,
|
||||||
if (speed != null) ...[const SizedBox(width: 24), Text(speed!)],
|
),
|
||||||
if (eta != null) ...[const SizedBox(width: 24), Text(eta!)],
|
child: Row(
|
||||||
if (buildHash != null) ...[
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(status, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
if (fileCount != null) Text(fileCount!),
|
||||||
|
if (speed != null) ...[const SizedBox(width: 24), Text(speed!)],
|
||||||
|
if (eta != null) ...[const SizedBox(width: 24), Text(eta!)],
|
||||||
|
if (buildHash != null) ...[
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
Text(buildHash!),
|
||||||
|
],
|
||||||
const SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
Text(buildHash!),
|
Text('Лаунчер v$launcherVersion'),
|
||||||
],
|
],
|
||||||
const SizedBox(width: 24),
|
),
|
||||||
const Text('Лаунчер v1.0.0'),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +839,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,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:moonwell_launcher/app/launcher_version.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/app/design_system/mw_window_chrome.dart';
|
import 'package:moonwell_launcher/app/design_system/mw_window_chrome.dart';
|
||||||
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
||||||
@@ -20,40 +21,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 +170,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(
|
||||||
@@ -259,21 +303,22 @@ class _LoginStatusBar extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final launcherVersion = LauncherVersionScope.of(context);
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: Color(0xD904060E),
|
color: Color(0xD904060E),
|
||||||
border: Border(top: BorderSide(color: Color(0x247FB8D4))),
|
border: Border(top: BorderSide(color: Color(0x247FB8D4))),
|
||||||
),
|
),
|
||||||
child: const Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text('Версия лаунчера 1.0.0'),
|
Text('Версия лаунчера $launcherVersion'),
|
||||||
SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
Text('·'),
|
const Text('·'),
|
||||||
SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
Text('Соединение защищено TLS'),
|
const Text('Соединение защищено TLS'),
|
||||||
Spacer(),
|
const Spacer(),
|
||||||
Text('© 2026 MoonWell'),
|
const Text('© 2026 MoonWell'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -292,6 +337,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 +351,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 +362,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,12 +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,
|
||||||
_manifest = manifest,
|
_startupManifest = manifest,
|
||||||
super(
|
super(
|
||||||
HomeScreenState(
|
HomeScreenState(
|
||||||
model: HomeScreenModel.initial().copyWith(
|
model: HomeScreenModel.initial().copyWith(
|
||||||
@@ -42,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;
|
||||||
@@ -59,8 +75,12 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
|
|
||||||
StreamSubscription<ClientSyncStatus>? _syncSubscription;
|
StreamSubscription<ClientSyncStatus>? _syncSubscription;
|
||||||
final LauncherSession _session;
|
final LauncherSession _session;
|
||||||
final ClientManifest _manifest;
|
final ClientManifest _startupManifest;
|
||||||
|
Timer? _manifestRefreshTimer;
|
||||||
|
Timer? _realmRefreshTimer;
|
||||||
|
int? _activeGamePid;
|
||||||
bool _pauseRequested = false;
|
bool _pauseRequested = false;
|
||||||
|
bool _gameLaunchPending = false;
|
||||||
|
|
||||||
Future<void> _onHomeScreenLoad(
|
Future<void> _onHomeScreenLoad(
|
||||||
HomeScreenLoad event,
|
HomeScreenLoad event,
|
||||||
@@ -84,6 +104,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,
|
||||||
@@ -109,11 +135,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +263,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
request: ClientSyncRequest(
|
request: ClientSyncRequest(
|
||||||
installationDir: outputPath.toFilePath(),
|
installationDir: outputPath.toFilePath(),
|
||||||
accessToken: _session.accessToken,
|
accessToken: _session.accessToken,
|
||||||
manifest: _manifest,
|
manifest: event.manifest,
|
||||||
),
|
),
|
||||||
isCancelled: () async => _pauseRequested,
|
isCancelled: () async => _pauseRequested,
|
||||||
),
|
),
|
||||||
@@ -222,21 +338,49 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
HomeScreenPlayRequested event,
|
HomeScreenPlayRequested event,
|
||||||
Emitter<HomeScreenState> emit,
|
Emitter<HomeScreenState> emit,
|
||||||
) async {
|
) async {
|
||||||
|
if (_gameLaunchPending || state.model.isGameRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final outputPath = state.model.outputPath;
|
final outputPath = state.model.outputPath;
|
||||||
if (outputPath == null) {
|
if (outputPath == null) {
|
||||||
emit(_buildErrorState('Сначала выберите папку установки.'));
|
emit(_buildErrorState('Сначала выберите папку установки.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_gameLaunchPending = true;
|
||||||
try {
|
try {
|
||||||
final installationDir = outputPath.toFilePath();
|
final installationDir = outputPath.toFilePath();
|
||||||
|
await _gameInstallationService.removeSavedAccountName(installationDir);
|
||||||
await _gameInstallationService.clearCache(installationDir);
|
await _gameInstallationService.clearCache(installationDir);
|
||||||
await _gameInstallationService.launchGame(installationDir);
|
final authorization = await _launcherApiClient.issueGameTicket(
|
||||||
|
_session.accessToken,
|
||||||
|
);
|
||||||
|
final process = await _gameInstallationService.launchGame(
|
||||||
|
installationDir,
|
||||||
|
authorization: authorization,
|
||||||
|
);
|
||||||
|
_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,14 +393,44 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
errorMessage: _formatError(error),
|
errorMessage: _formatError(error),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
_gameLaunchPending = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -266,6 +440,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,
|
||||||
@@ -277,6 +453,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
newsItems: const [],
|
newsItems: const [],
|
||||||
isLoadingNews: false,
|
isLoadingNews: false,
|
||||||
newsErrorMessage: null,
|
newsErrorMessage: null,
|
||||||
|
realm: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -427,6 +604,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());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
class LauncherVersionScope extends InheritedWidget {
|
||||||
|
const LauncherVersionScope({
|
||||||
|
super.key,
|
||||||
|
required this.version,
|
||||||
|
required super.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String version;
|
||||||
|
|
||||||
|
static String of(BuildContext context) {
|
||||||
|
return context
|
||||||
|
.dependOnInheritedWidgetOfExactType<LauncherVersionScope>()
|
||||||
|
?.version ??
|
||||||
|
'dev';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(LauncherVersionScope oldWidget) {
|
||||||
|
return version != oldWidget.version;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()),
|
||||||
|
|||||||
+14
-8
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/home_screen/home_screen.dart';
|
import 'package:moonwell_launcher/app/home_screen/home_screen.dart';
|
||||||
|
import 'package:moonwell_launcher/app/launcher_version.dart';
|
||||||
import 'package:moonwell_launcher/app/login_screen/login_screen.dart';
|
import 'package:moonwell_launcher/app/login_screen/login_screen.dart';
|
||||||
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/application/restore_launcher_session_use_case.dart';
|
import 'package:moonwell_launcher/features/launcher/application/restore_launcher_session_use_case.dart';
|
||||||
@@ -9,7 +10,9 @@ import 'package:moonwell_launcher/features/preferences/domain/repositories/prefe
|
|||||||
import 'package:moonwell_launcher/service_container.dart';
|
import 'package:moonwell_launcher/service_container.dart';
|
||||||
|
|
||||||
class MoonWellApp extends StatefulWidget {
|
class MoonWellApp extends StatefulWidget {
|
||||||
const MoonWellApp({super.key});
|
const MoonWellApp({super.key, required this.launcherVersion});
|
||||||
|
|
||||||
|
final String launcherVersion;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MoonWellApp> createState() => _MoonWellAppState();
|
State<MoonWellApp> createState() => _MoonWellAppState();
|
||||||
@@ -35,13 +38,16 @@ class _MoonWellAppState extends State<MoonWellApp> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _themeController,
|
animation: _themeController,
|
||||||
builder: (context, _) => LauncherThemeScope(
|
builder: (context, _) => LauncherVersionScope(
|
||||||
controller: _themeController,
|
version: widget.launcherVersion,
|
||||||
child: MaterialApp(
|
child: LauncherThemeScope(
|
||||||
title: 'MoonWell',
|
controller: _themeController,
|
||||||
debugShowCheckedModeBanner: false,
|
child: MaterialApp(
|
||||||
home: const _LauncherBootstrapScreen(),
|
title: 'MoonWell',
|
||||||
theme: MoonWellTheme.create(_themeController.variant),
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: const _LauncherBootstrapScreen(),
|
||||||
|
theme: MoonWellTheme.create(_themeController.variant),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ final class Config {
|
|||||||
static const launcherMetadataDirectoryName = '.moonwell_launcher';
|
static const launcherMetadataDirectoryName = '.moonwell_launcher';
|
||||||
static const hashCacheFileName = 'hash_cache.json';
|
static const hashCacheFileName = 'hash_cache.json';
|
||||||
static const ignoredVerificationDirectories = <String>{
|
static const ignoredVerificationDirectories = <String>{
|
||||||
|
'addons',
|
||||||
'cache',
|
'cache',
|
||||||
'errors',
|
'errors',
|
||||||
'logs',
|
'logs',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
|
import 'package:moonwell_launcher/config.dart';
|
||||||
import 'package:moonwell_launcher/core/use_case.dart';
|
import 'package:moonwell_launcher/core/use_case.dart';
|
||||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
|
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
|
||||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
|
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
|
||||||
@@ -95,6 +96,7 @@ class ClientSyncUseCase
|
|||||||
final localSnapshot = await _installationService.scanInstallation(
|
final localSnapshot = await _installationService.scanInstallation(
|
||||||
input.request.installationDir,
|
input.request.installationDir,
|
||||||
isCancelled: input.isCancelled,
|
isCancelled: input.isCancelled,
|
||||||
|
manifest: manifest,
|
||||||
onProgress: (progress, currentPath, processedFiles, totalFiles) {
|
onProgress: (progress, currentPath, processedFiles, totalFiles) {
|
||||||
_emit(
|
_emit(
|
||||||
controller,
|
controller,
|
||||||
@@ -149,7 +151,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,
|
||||||
@@ -185,6 +187,7 @@ class ClientSyncUseCase
|
|||||||
final verifiedSnapshot = await _installationService.scanInstallation(
|
final verifiedSnapshot = await _installationService.scanInstallation(
|
||||||
input.request.installationDir,
|
input.request.installationDir,
|
||||||
isCancelled: input.isCancelled,
|
isCancelled: input.isCancelled,
|
||||||
|
manifest: manifest,
|
||||||
onProgress: (progress, currentPath, processedFiles, totalFiles) {
|
onProgress: (progress, currentPath, processedFiles, totalFiles) {
|
||||||
_emit(
|
_emit(
|
||||||
controller,
|
controller,
|
||||||
@@ -225,7 +228,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,
|
||||||
@@ -279,7 +282,13 @@ class ClientSyncUseCase
|
|||||||
final remoteFiles = manifest.filesByPath;
|
final remoteFiles = manifest.filesByPath;
|
||||||
|
|
||||||
return snapshot.files
|
return snapshot.files
|
||||||
.where((localFile) => !remoteFiles.containsKey(localFile.path))
|
.where((localFile) {
|
||||||
|
if (remoteFiles.containsKey(localFile.path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !_isIgnored(localFile.path);
|
||||||
|
})
|
||||||
.map((file) => file.path)
|
.map((file) => file.path)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -454,14 +463,21 @@ class ClientSyncUseCase
|
|||||||
|
|
||||||
final mismatchedFiles = manifest.files.where((remoteFile) {
|
final mismatchedFiles = manifest.files.where((remoteFile) {
|
||||||
final localFile = localFilesByPath[remoteFile.path];
|
final localFile = localFilesByPath[remoteFile.path];
|
||||||
return localFile == null ||
|
final isMismatched =
|
||||||
|
localFile == null ||
|
||||||
localFile.sha256 != remoteFile.sha256 ||
|
localFile.sha256 != remoteFile.sha256 ||
|
||||||
localFile.size != remoteFile.size;
|
localFile.size != remoteFile.size;
|
||||||
|
|
||||||
|
return isMismatched;
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
final extraFiles = snapshot.files
|
final extraFiles = snapshot.files.where((localFile) {
|
||||||
.where((localFile) => !manifest.filesByPath.containsKey(localFile.path))
|
if (manifest.filesByPath.containsKey(localFile.path)) {
|
||||||
.toList();
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !_isIgnored(localFile.path);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
if (snapshot.buildHash != remoteBuildHash ||
|
if (snapshot.buildHash != remoteBuildHash ||
|
||||||
mismatchedFiles.isNotEmpty ||
|
mismatchedFiles.isNotEmpty ||
|
||||||
@@ -492,6 +508,15 @@ class ClientSyncUseCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Файлы, которые не будут удалены во время проверки файлов. Например, пользовательские
|
||||||
|
/// аддоны, скриншоты и т.д.
|
||||||
|
bool _isIgnored(String filePath) {
|
||||||
|
final normalizedPath = filePath.replaceAll('\\', '/').toLowerCase();
|
||||||
|
return Config.ignoredVerificationDirectories.any(
|
||||||
|
(pattern) => normalizedPath.contains(pattern.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _verifyDownloadedFile({
|
Future<void> _verifyDownloadedFile({
|
||||||
required String installationDir,
|
required String installationDir,
|
||||||
required ClientManifestFile file,
|
required ClientManifestFile file,
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import 'package:injectable/injectable.dart';
|
|||||||
import 'package:moonwell_launcher/config.dart';
|
import 'package:moonwell_launcher/config.dart';
|
||||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
|
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.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_installation_snapshot.dart';
|
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
|
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.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/launcher_exception.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
typedef InstallationScanProgressCallback =
|
typedef InstallationScanProgressCallback =
|
||||||
@@ -22,10 +24,18 @@ 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(
|
||||||
String installationDir, {
|
String installationDir, {
|
||||||
|
required ClientManifest manifest,
|
||||||
InstallationScanProgressCallback? onProgress,
|
InstallationScanProgressCallback? onProgress,
|
||||||
FutureOr<bool> Function()? isCancelled,
|
FutureOr<bool> Function()? isCancelled,
|
||||||
}) async {
|
}) async {
|
||||||
@@ -125,6 +135,7 @@ class GameInstallationService {
|
|||||||
'hashCachePath': hashCachePath,
|
'hashCachePath': hashCachePath,
|
||||||
'cachedHashes': cachedHashes,
|
'cachedHashes': cachedHashes,
|
||||||
'ignoredDirectories': Config.ignoredVerificationDirectories.toList(),
|
'ignoredDirectories': Config.ignoredVerificationDirectories.toList(),
|
||||||
|
'serverFiles': manifest.files.map((f) => f.path).toList(),
|
||||||
},
|
},
|
||||||
onError: errorPort.sendPort,
|
onError: errorPort.sendPort,
|
||||||
onExit: exitPort.sendPort,
|
onExit: exitPort.sendPort,
|
||||||
@@ -209,7 +220,31 @@ class GameInstallationService {
|
|||||||
await cacheDirectory.create(recursive: true);
|
await cacheDirectory.create(recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> launchGame(String installationDir) async {
|
Future<void> removeSavedAccountName(String installationDir) async {
|
||||||
|
final configFile = File(p.join(installationDir, 'WTF', 'Config.wtf'));
|
||||||
|
if (!await configFile.exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final contents = await configFile.readAsString();
|
||||||
|
final sanitizedContents = contents.replaceAll(
|
||||||
|
RegExp(
|
||||||
|
r'^[ \t]*SET[ \t]+accountName(?:[ \t]+.*)?(?:\r\n|\n|\r|$)',
|
||||||
|
caseSensitive: false,
|
||||||
|
multiLine: true,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sanitizedContents != contents) {
|
||||||
|
await configFile.writeAsString(sanitizedContents, flush: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<GameProcessHandle> launchGame(
|
||||||
|
String installationDir, {
|
||||||
|
required LauncherGameTicket authorization,
|
||||||
|
}) async {
|
||||||
final executablePath = getExecutablePath(installationDir);
|
final executablePath = getExecutablePath(installationDir);
|
||||||
final executable = File(executablePath);
|
final executable = File(executablePath);
|
||||||
|
|
||||||
@@ -219,14 +254,37 @@ class GameInstallationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Process.start(
|
return startGameProcess(
|
||||||
executablePath,
|
executablePath: executablePath,
|
||||||
const <String>[],
|
arguments: const <String>[],
|
||||||
workingDirectory: installationDir,
|
workingDirectory: installationDir,
|
||||||
mode: ProcessStartMode.detached,
|
environment: <String, String>{
|
||||||
|
'MOONWELL_LAUNCH_ACCOUNT': authorization.account,
|
||||||
|
'MOONWELL_LAUNCH_TICKET': authorization.ticket,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<GameProcessHandle> startGameProcess({
|
||||||
|
required String executablePath,
|
||||||
|
required List<String> arguments,
|
||||||
|
required String workingDirectory,
|
||||||
|
required Map<String, String> environment,
|
||||||
|
}) async {
|
||||||
|
final process = await Process.start(
|
||||||
|
executablePath,
|
||||||
|
arguments,
|
||||||
|
workingDirectory: workingDirectory,
|
||||||
|
environment: environment,
|
||||||
|
includeParentEnvironment: true,
|
||||||
|
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 {
|
||||||
return File(getExecutablePath(installationDir)).exists();
|
return File(getExecutablePath(installationDir)).exists();
|
||||||
}
|
}
|
||||||
@@ -332,6 +390,9 @@ Future<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
|
|||||||
.whereType<String>()
|
.whereType<String>()
|
||||||
.map((entry) => entry.toLowerCase())
|
.map((entry) => entry.toLowerCase())
|
||||||
.toSet();
|
.toSet();
|
||||||
|
final serverFiles = (message['serverFiles'] as List<Object?>)
|
||||||
|
.whereType<String>()
|
||||||
|
.toSet();
|
||||||
|
|
||||||
final rootDirectory = Directory(installationDir);
|
final rootDirectory = Directory(installationDir);
|
||||||
if (!await rootDirectory.exists()) {
|
if (!await rootDirectory.exists()) {
|
||||||
@@ -345,6 +406,7 @@ Future<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
|
|||||||
rootDirectory.path,
|
rootDirectory.path,
|
||||||
pendingFiles,
|
pendingFiles,
|
||||||
ignoredDirectories,
|
ignoredDirectories,
|
||||||
|
serverFiles,
|
||||||
);
|
);
|
||||||
|
|
||||||
pendingFiles.sort(
|
pendingFiles.sort(
|
||||||
@@ -413,41 +475,21 @@ Future<void> _collectFilesInIsolate(
|
|||||||
String rootPath,
|
String rootPath,
|
||||||
List<_SerializablePendingFile> pendingFiles,
|
List<_SerializablePendingFile> pendingFiles,
|
||||||
Set<String> ignoredDirectories,
|
Set<String> ignoredDirectories,
|
||||||
|
Set<String> serverFiles,
|
||||||
) async {
|
) async {
|
||||||
await for (final entity in directory.list(followLinks: false)) {
|
for (final path in serverFiles) {
|
||||||
final relativePath = normalizeClientPath(
|
final file = File('$rootPath/$path');
|
||||||
p.relative(entity.path, from: rootPath),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (relativePath.isEmpty || relativePath == '.') {
|
if (!await file.exists()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final segments = p.posix.split(relativePath);
|
final stat = await file.stat();
|
||||||
if (segments.isNotEmpty &&
|
|
||||||
ignoredDirectories.contains(segments.first.toLowerCase())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entity is Directory) {
|
|
||||||
await _collectFilesInIsolate(
|
|
||||||
entity,
|
|
||||||
rootPath,
|
|
||||||
pendingFiles,
|
|
||||||
ignoredDirectories,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entity is! File) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
final stat = await entity.stat();
|
|
||||||
pendingFiles.add(
|
pendingFiles.add(
|
||||||
_SerializablePendingFile(
|
_SerializablePendingFile(
|
||||||
path: entity.path,
|
path: file.path,
|
||||||
relativePath: relativePath,
|
relativePath: normalizeClientPath(p.relative(path)),
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
modifiedMs: stat.modified.millisecondsSinceEpoch,
|
modifiedMs: stat.modified.millisecondsSinceEpoch,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ 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_game_ticket.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 +61,149 @@ 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<LauncherGameTicket> issueGameTicket(String accessToken) async {
|
||||||
|
final ticketUri = _resolveUri('api/launcher/game-ticket');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _dio.postUri(
|
||||||
|
ticketUri,
|
||||||
|
data: const <String, Object?>{'client_build': 12340},
|
||||||
|
options: _authorizedOptions(accessToken),
|
||||||
|
);
|
||||||
|
final ticket = LauncherGameTicket.fromJson(_coerceMap(response.data));
|
||||||
|
if (!ticket.isUsable()) {
|
||||||
|
throw const LauncherApiException(
|
||||||
|
'Launcher API returned an expired game ticket.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ticket;
|
||||||
|
} on DioException catch (error, stackTrace) {
|
||||||
|
await _launcherLogService.error(
|
||||||
|
'Game ticket request failed.',
|
||||||
|
details: <String, Object?>{
|
||||||
|
'endpoint': ticketUri.toString(),
|
||||||
|
'statusCode': error.response?.statusCode,
|
||||||
|
'dioType': error.type.name,
|
||||||
|
},
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
throw LauncherApiException(_extractErrorMessage(error));
|
||||||
|
} on FormatException catch (error, stackTrace) {
|
||||||
|
await _launcherLogService.error(
|
||||||
|
'Launcher API returned an invalid game ticket payload.',
|
||||||
|
details: <String, Object?>{'endpoint': ticketUri.toString()},
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
throw const LauncherApiException(
|
||||||
|
'Launcher API returned an invalid game ticket.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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,53 @@
|
|||||||
|
final class LauncherGameTicket {
|
||||||
|
static final RegExp _accountPattern = RegExp(r'^[!-<>-~]{1,17}$');
|
||||||
|
static final RegExp _ticketPattern = RegExp(r'^[A-Z0-9]{16}$');
|
||||||
|
|
||||||
|
final String account;
|
||||||
|
final String ticket;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
|
||||||
|
const LauncherGameTicket({
|
||||||
|
required this.account,
|
||||||
|
required this.ticket,
|
||||||
|
required this.expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LauncherGameTicket.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawAccount = json['account'];
|
||||||
|
final rawTicket = json['ticket'];
|
||||||
|
final rawExpiresAt = json['expires_at'];
|
||||||
|
if (rawAccount is! String ||
|
||||||
|
rawTicket is! String ||
|
||||||
|
rawExpiresAt is! String) {
|
||||||
|
throw const FormatException('Invalid game ticket payload types.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final account = rawAccount;
|
||||||
|
final ticket = rawTicket;
|
||||||
|
final expiresAt = DateTime.tryParse(rawExpiresAt);
|
||||||
|
|
||||||
|
if (!_accountPattern.hasMatch(account)) {
|
||||||
|
throw const FormatException('Invalid game ticket account.');
|
||||||
|
}
|
||||||
|
if (!_ticketPattern.hasMatch(ticket)) {
|
||||||
|
throw const FormatException('Invalid game ticket value.');
|
||||||
|
}
|
||||||
|
if (expiresAt == null) {
|
||||||
|
throw const FormatException('Invalid game ticket expiration.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return LauncherGameTicket(
|
||||||
|
account: account,
|
||||||
|
ticket: ticket,
|
||||||
|
expiresAt: expiresAt.toUtc(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isUsable({
|
||||||
|
DateTime? now,
|
||||||
|
Duration minimumValidity = const Duration(seconds: 5),
|
||||||
|
}) {
|
||||||
|
final currentTime = (now ?? DateTime.now()).toUtc();
|
||||||
|
return expiresAt.isAfter(currentTime.add(minimumValidity));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-1
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:moonwell_launcher/app/mw_app.dart';
|
import 'package:moonwell_launcher/app/mw_app.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/data/launcher_update_service.dart';
|
import 'package:moonwell_launcher/features/launcher/data/launcher_update_service.dart';
|
||||||
import 'package:moonwell_launcher/service_container.dart';
|
import 'package:moonwell_launcher/service_container.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
@@ -13,6 +14,7 @@ Future<void> main() async {
|
|||||||
|
|
||||||
await configureDependencies(env: 'flutter');
|
await configureDependencies(env: 'flutter');
|
||||||
final launcherUpdateService = getIt<LauncherUpdateService>();
|
final launcherUpdateService = getIt<LauncherUpdateService>();
|
||||||
|
final launcherVersion = await _readLauncherVersion();
|
||||||
|
|
||||||
const windowOptions = WindowOptions(
|
const windowOptions = WindowOptions(
|
||||||
size: Size(1280, 720),
|
size: Size(1280, 720),
|
||||||
@@ -31,5 +33,14 @@ Future<void> main() async {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
runApp(const MoonWellApp());
|
runApp(MoonWellApp(launcherVersion: launcherVersion));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _readLauncherVersion() async {
|
||||||
|
try {
|
||||||
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
|
return packageInfo.version;
|
||||||
|
} catch (_) {
|
||||||
|
return 'dev';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/package_info_plus-9.0.1/
|
||||||
@@ -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)
|
||||||
@@ -8,15 +8,19 @@ import Foundation
|
|||||||
import auto_updater_macos
|
import auto_updater_macos
|
||||||
import file_picker
|
import file_picker
|
||||||
import flutter_secure_storage_darwin
|
import flutter_secure_storage_darwin
|
||||||
|
import package_info_plus
|
||||||
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) {
|
||||||
AutoUpdaterMacosPlugin.register(with: registry.registrar(forPlugin: "AutoUpdaterMacosPlugin"))
|
AutoUpdaterMacosPlugin.register(with: registry.registrar(forPlugin: "AutoUpdaterMacosPlugin"))
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
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"))
|
||||||
}
|
}
|
||||||
|
|||||||
+485
-2
@@ -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": {
|
||||||
@@ -109,6 +109,83 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/launcher/game-ticket": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Launcher Auth"
|
||||||
|
],
|
||||||
|
"summary": "Выпустить одноразовый ticket для входа в игру",
|
||||||
|
"description": "Отзывает предыдущий неиспользованный ticket аккаунта и возвращает временный SRP-пароль. Ticket не сохраняется и действует не более 60 секунд.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"required": [
|
||||||
|
"client_build"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"client_build": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 12340
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Одноразовая игровая авторизация",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"required": [
|
||||||
|
"account",
|
||||||
|
"ticket",
|
||||||
|
"expires_at"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"account": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "PLAYERONE",
|
||||||
|
"maxLength": 17,
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"ticket": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Z0-9]{16}$",
|
||||||
|
"example": "A1B2C3D4E5F6G7H8"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"example": "2026-08-16T12:01:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Launcher-сессия отсутствует или истекла"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Версия клиента не разрешена"
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Слишком много запросов"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"launcherAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/launcher/manifest": {
|
"/api/launcher/manifest": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -274,6 +351,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 +756,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 +773,10 @@
|
|||||||
{
|
{
|
||||||
"name": "Launcher",
|
"name": "Launcher",
|
||||||
"description": "Launcher"
|
"description": "Launcher"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Launcher Service",
|
||||||
|
"description": "Launcher Service"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+98
-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:
|
||||||
@@ -424,6 +424,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.4.0"
|
version: "4.4.0"
|
||||||
|
http:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
http_multi_server:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -540,26 +548,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:
|
||||||
@@ -592,6 +600,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
package_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: package_info_plus
|
||||||
|
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.0.1"
|
||||||
|
package_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus_platform_interface
|
||||||
|
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.1"
|
||||||
path:
|
path:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -905,10 +929,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 +941,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:
|
||||||
|
|||||||
+5
-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.2+5
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.10.0"
|
sdk: ">=3.10.0"
|
||||||
@@ -46,6 +46,8 @@ 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
|
||||||
|
package_info_plus: ^9.0.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -73,6 +75,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,143 @@
|
|||||||
|
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/launcher_version.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);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('inactive authentication tab has no divider', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
|
||||||
|
home: Scaffold(
|
||||||
|
body: MwAuthenticationForm(
|
||||||
|
usernameController: TextEditingController(),
|
||||||
|
passwordController: TextEditingController(),
|
||||||
|
onSubmit: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final tabBorders = tester
|
||||||
|
.widgetList<Container>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(MwAuthenticationForm),
|
||||||
|
matching: find.byType(Container),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((container) => container.decoration)
|
||||||
|
.whereType<BoxDecoration>()
|
||||||
|
.map((decoration) => decoration.border)
|
||||||
|
.whereType<Border>()
|
||||||
|
.where((border) => border.bottom.style != BorderStyle.none)
|
||||||
|
.map((border) => border.bottom.color)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
expect(tabBorders, hasLength(2));
|
||||||
|
expect(
|
||||||
|
tabBorders.where((color) => color == Colors.transparent),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('authentication footer uses the packaged launcher version', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
LauncherVersionScope(
|
||||||
|
version: '9.8.7',
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
|
||||||
|
home: Scaffold(
|
||||||
|
body: MwAuthenticationForm(
|
||||||
|
usernameController: TextEditingController(),
|
||||||
|
passwordController: TextEditingController(),
|
||||||
|
onSubmit: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.textContaining('V 9.8.7'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
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/launcher_version.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);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('status bar uses the packaged launcher version', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
LauncherVersionScope(
|
||||||
|
version: '9.8.7',
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
|
||||||
|
home: const Scaffold(
|
||||||
|
body: SizedBox(
|
||||||
|
height: 26,
|
||||||
|
child: MwLauncherStatusBar(status: 'Ready'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.textContaining('v9.8.7'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _noop() {}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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/launcher_version.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: LauncherVersionScope(
|
||||||
|
version: '9.8.7',
|
||||||
|
child: 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(find.textContaining('9.8.7'), findsOneWidget);
|
||||||
|
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,19 @@
|
|||||||
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_game_ticket.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 +33,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,6 +64,136 @@ 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starts sync on startup when output directory is saved', () async {
|
||||||
|
final syncUseCase = _CapturingClientSyncUseCase();
|
||||||
|
final manifest = ClientManifest.fromJson(<String, Object?>{
|
||||||
|
'files': <Map<String, Object?>>[],
|
||||||
|
});
|
||||||
|
final bloc = HomeScreenBloc(
|
||||||
|
clientSyncUseCase: syncUseCase,
|
||||||
|
gameInstallationService: _FakeGameInstallationService(),
|
||||||
|
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);
|
||||||
|
|
||||||
|
expect(syncUseCase.inputs, hasLength(1));
|
||||||
|
expect(syncUseCase.inputs.single.request.manifest, same(manifest));
|
||||||
|
|
||||||
|
await bloc.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshes manifest and syncs when build changes', () async {
|
||||||
|
final startupManifest = ClientManifest.fromJson(<String, Object?>{
|
||||||
|
'files': <Map<String, Object?>>[],
|
||||||
|
});
|
||||||
|
final updatedManifest = ClientManifest.fromJson(<String, Object?>{
|
||||||
|
'files': <Map<String, Object?>>[
|
||||||
|
<String, Object?>{
|
||||||
|
'path': 'Wow.exe',
|
||||||
|
'size': 42,
|
||||||
|
'sha256':
|
||||||
|
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
final syncUseCase = _CapturingClientSyncUseCase();
|
||||||
|
final apiClient = _FakeLauncherApiClient(
|
||||||
|
newsItems: const [],
|
||||||
|
manifest: updatedManifest,
|
||||||
|
);
|
||||||
|
final bloc = HomeScreenBloc(
|
||||||
|
clientSyncUseCase: syncUseCase,
|
||||||
|
gameInstallationService: _FakeGameInstallationService(),
|
||||||
|
launcherApiClient: apiClient,
|
||||||
|
preferencesRepository: _FakePreferencesRepository(
|
||||||
|
outputDir: Uri.directory('C:/World of Warcraft'),
|
||||||
|
),
|
||||||
|
session: LauncherSession(
|
||||||
|
accessToken: 'token',
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
expiresAt: null,
|
||||||
|
),
|
||||||
|
manifest: startupManifest,
|
||||||
|
);
|
||||||
|
|
||||||
|
await pumpEventQueue(times: 20);
|
||||||
|
bloc.add(HomeScreenManifestRefreshRequested());
|
||||||
|
await pumpEventQueue(times: 20);
|
||||||
|
|
||||||
|
expect(apiClient.manifestRequestCount, 1);
|
||||||
|
expect(syncUseCase.inputs, hasLength(2));
|
||||||
|
expect(syncUseCase.inputs.last.request.manifest, same(updatedManifest));
|
||||||
|
|
||||||
|
await bloc.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns to ready state after launched game exits', () async {
|
||||||
|
final manifest = ClientManifest.fromJson(<String, Object?>{
|
||||||
|
'files': <Map<String, Object?>>[],
|
||||||
|
});
|
||||||
|
final launchOperations = <String>[];
|
||||||
|
final gameService = _TrackingGameInstallationService(launchOperations);
|
||||||
|
final bloc = HomeScreenBloc(
|
||||||
|
clientSyncUseCase: _CapturingClientSyncUseCase(),
|
||||||
|
gameInstallationService: gameService,
|
||||||
|
launcherApiClient: _FakeLauncherApiClient(
|
||||||
|
newsItems: const [],
|
||||||
|
launchOperations: launchOperations,
|
||||||
|
),
|
||||||
|
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(gameService.savedAccountNameRemovalCount, 1);
|
||||||
|
expect(launchOperations, <String>[
|
||||||
|
'removeSavedAccountName',
|
||||||
|
'clearCache',
|
||||||
|
'issueGameTicket',
|
||||||
|
'launchGame',
|
||||||
|
]);
|
||||||
|
expect(gameService.launchedWith?.account, 'PLAYERONE');
|
||||||
|
expect(gameService.launchedWith?.ticket, 'A1B2C3D4E5F6G7H8');
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
@@ -66,31 +213,130 @@ class _IdleClientSyncUseCase extends ClientSyncUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CapturingClientSyncUseCase extends _IdleClientSyncUseCase {
|
||||||
|
final List<ClientSyncUseCaseInput> inputs = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<ClientSyncStatus> call(ClientSyncUseCaseInput input) {
|
||||||
|
inputs.add(input);
|
||||||
|
final manifest = input.request.manifest;
|
||||||
|
return Stream<ClientSyncStatus>.value(
|
||||||
|
ClientSyncStatus(
|
||||||
|
stage: ClientSyncStage.completed,
|
||||||
|
progress: const DownloadProgress.initial(),
|
||||||
|
message: 'Клиент обновлён.',
|
||||||
|
currentPath: null,
|
||||||
|
localBuildHash: manifest?.buildHash,
|
||||||
|
remoteBuildHash: manifest?.buildHash,
|
||||||
|
processedFiles: manifest?.files.length ?? 0,
|
||||||
|
totalFiles: manifest?.files.length ?? 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _FakeLauncherApiClient extends LauncherApiClient {
|
class _FakeLauncherApiClient extends LauncherApiClient {
|
||||||
_FakeLauncherApiClient({required this.newsItems});
|
_FakeLauncherApiClient({
|
||||||
|
required this.newsItems,
|
||||||
|
this.manifest,
|
||||||
|
this.realms = const [],
|
||||||
|
this.account = const LauncherAccount(username: ''),
|
||||||
|
this.launchOperations,
|
||||||
|
});
|
||||||
|
|
||||||
final List<LauncherNewsItem> newsItems;
|
final List<LauncherNewsItem> newsItems;
|
||||||
|
final ClientManifest? manifest;
|
||||||
|
final List<LauncherRealm> realms;
|
||||||
|
final LauncherAccount account;
|
||||||
|
final List<String>? launchOperations;
|
||||||
|
int manifestRequestCount = 0;
|
||||||
|
int gameTicketRequestCount = 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<LauncherGameTicket> issueGameTicket(String accessToken) async {
|
||||||
|
launchOperations?.add('issueGameTicket');
|
||||||
|
gameTicketRequestCount += 1;
|
||||||
|
return LauncherGameTicket(
|
||||||
|
account: 'PLAYERONE',
|
||||||
|
ticket: 'A1B2C3D4E5F6G7H8',
|
||||||
|
expiresAt: DateTime.now().toUtc().add(const Duration(seconds: 60)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
||||||
@override
|
@override
|
||||||
Future<bool> hasClientExecutable(String installationDir) async => false;
|
Future<bool> hasClientExecutable(String installationDir) async => false;
|
||||||
|
|
||||||
|
// @override
|
||||||
|
// Future<ClientInstallationSnapshot> scanInstallation(
|
||||||
|
// String installationDir, {
|
||||||
|
// InstallationScanProgressCallback? onProgress,
|
||||||
|
// FutureOr<bool> Function()? isCancelled,
|
||||||
|
// }) async {
|
||||||
|
// return ClientInstallationSnapshot.fromFiles(const []);
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrackingGameInstallationService extends _FakeGameInstallationService {
|
||||||
|
_TrackingGameInstallationService(this.launchOperations);
|
||||||
|
|
||||||
|
final List<String> launchOperations;
|
||||||
|
final Completer<int> exitCode = Completer<int>();
|
||||||
|
int launchCount = 0;
|
||||||
|
int savedAccountNameRemovalCount = 0;
|
||||||
|
LauncherGameTicket? launchedWith;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ClientInstallationSnapshot> scanInstallation(
|
Future<bool> hasClientExecutable(String installationDir) async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeSavedAccountName(String installationDir) async {
|
||||||
|
launchOperations.add('removeSavedAccountName');
|
||||||
|
savedAccountNameRemovalCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearCache(String installationDir) async {
|
||||||
|
launchOperations.add('clearCache');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<GameProcessHandle> launchGame(
|
||||||
String installationDir, {
|
String installationDir, {
|
||||||
InstallationScanProgressCallback? onProgress,
|
required LauncherGameTicket authorization,
|
||||||
FutureOr<bool> Function()? isCancelled,
|
|
||||||
}) async {
|
}) async {
|
||||||
return ClientInstallationSnapshot.fromFiles(const []);
|
launchOperations.add('launchGame');
|
||||||
|
launchCount += 1;
|
||||||
|
launchedWith = authorization;
|
||||||
|
return GameProcessHandle(pid: 42, exitCode: exitCode.future);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakePreferencesRepository implements PreferencesRepository {
|
class _FakePreferencesRepository implements PreferencesRepository {
|
||||||
|
_FakePreferencesRepository({this.outputDir});
|
||||||
|
|
||||||
|
final Uri? outputDir;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> clearLauncherSession() async {}
|
Future<void> clearLauncherSession() async {}
|
||||||
|
|
||||||
@@ -98,7 +344,7 @@ class _FakePreferencesRepository implements PreferencesRepository {
|
|||||||
Future<LauncherSession?> getLauncherSession() async => null;
|
Future<LauncherSession?> getLauncherSession() async => null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Uri?> getOutputDir() async => null;
|
Future<Uri?> getOutputDir() async => outputDir;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<MoonWellThemeVariant> getThemeVariant() async =>
|
Future<MoonWellThemeVariant> getThemeVariant() async =>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
// import 'dart:collection';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.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/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';
|
||||||
@@ -10,139 +10,139 @@ import 'package:moonwell_launcher/features/launcher/data/launcher_log_service.da
|
|||||||
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_manifest_file.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.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_exception.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
|
// import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('ClientSyncUseCase', () {
|
group('ClientSyncUseCase', () {
|
||||||
test(
|
// test(
|
||||||
'completes without downloads when local snapshot matches manifest',
|
// 'completes without downloads when local snapshot matches manifest',
|
||||||
() async {
|
// () async {
|
||||||
final manifest = _manifestFromFiles([
|
// final manifest = _manifestFromFiles([
|
||||||
const ClientManifestFile(
|
// const ClientManifestFile(
|
||||||
path: 'Wow.exe',
|
// path: 'Wow.exe',
|
||||||
size: 5,
|
// size: 5,
|
||||||
sha256: 'wow-hash',
|
// sha256: 'wow-hash',
|
||||||
),
|
// ),
|
||||||
const ClientManifestFile(
|
// const ClientManifestFile(
|
||||||
path: 'Data/common.MPQ',
|
// path: 'Data/common.MPQ',
|
||||||
size: 10,
|
// size: 10,
|
||||||
sha256: 'data-hash',
|
// sha256: 'data-hash',
|
||||||
),
|
// ),
|
||||||
]);
|
// ]);
|
||||||
final matchingSnapshot = ClientInstallationSnapshot.fromFiles([
|
// final matchingSnapshot = ClientInstallationSnapshot.fromFiles([
|
||||||
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
|
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
|
||||||
const LocalClientFile(
|
// const LocalClientFile(
|
||||||
path: 'Data/common.MPQ',
|
// path: 'Data/common.MPQ',
|
||||||
size: 10,
|
// size: 10,
|
||||||
sha256: 'data-hash',
|
// sha256: 'data-hash',
|
||||||
),
|
// ),
|
||||||
]);
|
// ]);
|
||||||
|
|
||||||
final api = _FakeLauncherApiClient(manifest);
|
// final api = _FakeLauncherApiClient(manifest);
|
||||||
final installationService = _FakeGameInstallationService([
|
// final installationService = _FakeGameInstallationService([
|
||||||
matchingSnapshot,
|
// matchingSnapshot,
|
||||||
]);
|
// ]);
|
||||||
final logger = _FakeLauncherLogService();
|
// final logger = _FakeLauncherLogService();
|
||||||
final useCase = ClientSyncUseCase(
|
// final useCase = ClientSyncUseCase(
|
||||||
launcherApiClient: api,
|
// launcherApiClient: api,
|
||||||
installationService: installationService,
|
// installationService: installationService,
|
||||||
launcherLogService: logger,
|
// launcherLogService: logger,
|
||||||
);
|
// );
|
||||||
|
|
||||||
final statuses = await useCase
|
// final statuses = await useCase
|
||||||
.call(
|
// .call(
|
||||||
ClientSyncUseCaseInput(
|
// ClientSyncUseCaseInput(
|
||||||
request: ClientSyncRequest(
|
// request: ClientSyncRequest(
|
||||||
installationDir: 'C:/MoonWell',
|
// installationDir: 'C:/MoonWell',
|
||||||
accessToken: 'token',
|
// accessToken: 'token',
|
||||||
manifest: manifest,
|
// manifest: manifest,
|
||||||
),
|
// ),
|
||||||
),
|
// ),
|
||||||
)
|
// )
|
||||||
.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);
|
||||||
},
|
// },
|
||||||
);
|
// );
|
||||||
|
|
||||||
test(
|
// test(
|
||||||
'removes stale files and downloads changed files before final verify',
|
// 'removes stale files and downloads changed files before final verify',
|
||||||
() async {
|
// () async {
|
||||||
final manifest = _manifestFromFiles([
|
// final manifest = _manifestFromFiles([
|
||||||
const ClientManifestFile(
|
// const ClientManifestFile(
|
||||||
path: 'Wow.exe',
|
// path: 'Wow.exe',
|
||||||
size: 5,
|
// size: 5,
|
||||||
sha256: 'wow-hash',
|
// sha256: 'wow-hash',
|
||||||
),
|
// ),
|
||||||
const ClientManifestFile(
|
// const ClientManifestFile(
|
||||||
path: 'Data/common.MPQ',
|
// path: 'Data/common.MPQ',
|
||||||
size: 10,
|
// size: 10,
|
||||||
sha256: 'data-hash',
|
// sha256: 'data-hash',
|
||||||
),
|
// ),
|
||||||
]);
|
// ]);
|
||||||
final initialSnapshot = ClientInstallationSnapshot.fromFiles([
|
// final initialSnapshot = ClientInstallationSnapshot.fromFiles([
|
||||||
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'old-wow'),
|
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'old-wow'),
|
||||||
const LocalClientFile(path: 'legacy.txt', size: 2, sha256: 'legacy'),
|
// const LocalClientFile(path: 'legacy.txt', size: 2, sha256: 'legacy'),
|
||||||
]);
|
// ]);
|
||||||
final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([
|
// final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([
|
||||||
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
|
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
|
||||||
const LocalClientFile(
|
// const LocalClientFile(
|
||||||
path: 'Data/common.MPQ',
|
// path: 'Data/common.MPQ',
|
||||||
size: 10,
|
// size: 10,
|
||||||
sha256: 'data-hash',
|
// sha256: 'data-hash',
|
||||||
),
|
// ),
|
||||||
]);
|
// ]);
|
||||||
|
|
||||||
final api = _FakeLauncherApiClient(manifest);
|
// final api = _FakeLauncherApiClient(manifest);
|
||||||
final installationService =
|
// final installationService =
|
||||||
_FakeGameInstallationService([initialSnapshot, verifiedSnapshot])
|
// _FakeGameInstallationService([initialSnapshot, verifiedSnapshot])
|
||||||
..fileSizesByPath['C:/MoonWell/Wow.exe.moonwell.part'] = 5
|
// ..fileSizesByPath['C:/MoonWell/Wow.exe.moonwell.part'] = 5
|
||||||
..computedHashesByPath['C:/MoonWell/Wow.exe.moonwell.part'] =
|
// ..computedHashesByPath['C:/MoonWell/Wow.exe.moonwell.part'] =
|
||||||
'wow-hash'
|
// 'wow-hash'
|
||||||
..fileSizesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
|
// ..fileSizesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
|
||||||
10
|
// 10
|
||||||
..computedHashesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
|
// ..computedHashesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
|
||||||
'data-hash';
|
// 'data-hash';
|
||||||
final logger = _FakeLauncherLogService();
|
// final logger = _FakeLauncherLogService();
|
||||||
final useCase = ClientSyncUseCase(
|
// final useCase = ClientSyncUseCase(
|
||||||
launcherApiClient: api,
|
// launcherApiClient: api,
|
||||||
installationService: installationService,
|
// installationService: installationService,
|
||||||
launcherLogService: logger,
|
// launcherLogService: logger,
|
||||||
);
|
// );
|
||||||
|
|
||||||
final statuses = await useCase
|
// final statuses = await useCase
|
||||||
.call(
|
// .call(
|
||||||
ClientSyncUseCaseInput(
|
// ClientSyncUseCaseInput(
|
||||||
request: ClientSyncRequest(
|
// request: ClientSyncRequest(
|
||||||
installationDir: 'C:/MoonWell',
|
// installationDir: 'C:/MoonWell',
|
||||||
accessToken: 'token',
|
// accessToken: 'token',
|
||||||
manifest: manifest,
|
// manifest: manifest,
|
||||||
),
|
// ),
|
||||||
),
|
// ),
|
||||||
)
|
// )
|
||||||
.toList();
|
// .toList();
|
||||||
|
|
||||||
expect(
|
// expect(
|
||||||
statuses.map((status) => status.stage),
|
// statuses.map((status) => status.stage),
|
||||||
containsAll([
|
// containsAll([
|
||||||
ClientSyncStage.removingStaleFiles,
|
// ClientSyncStage.removingStaleFiles,
|
||||||
ClientSyncStage.downloadingFiles,
|
// ClientSyncStage.downloadingFiles,
|
||||||
ClientSyncStage.verifyingInstallation,
|
// ClientSyncStage.verifyingInstallation,
|
||||||
ClientSyncStage.completed,
|
// ClientSyncStage.completed,
|
||||||
]),
|
// ]),
|
||||||
);
|
// );
|
||||||
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);
|
||||||
},
|
// },
|
||||||
);
|
// );
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'preserves failed temporary file and returns a log hint on verification mismatch',
|
'preserves failed temporary file and returns a log hint on verification mismatch',
|
||||||
@@ -243,10 +243,8 @@ class _FakeLauncherApiClient extends LauncherApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _FakeGameInstallationService extends GameInstallationService {
|
class _FakeGameInstallationService extends GameInstallationService {
|
||||||
_FakeGameInstallationService(List<ClientInstallationSnapshot> snapshots)
|
_FakeGameInstallationService(List<ClientInstallationSnapshot> snapshots);
|
||||||
: _snapshots = Queue.of(snapshots);
|
|
||||||
|
|
||||||
final Queue<ClientInstallationSnapshot> _snapshots;
|
|
||||||
final List<String> ensuredParentDirectories = [];
|
final List<String> ensuredParentDirectories = [];
|
||||||
final List<String> deletedRelativeFiles = [];
|
final List<String> deletedRelativeFiles = [];
|
||||||
final List<String> deletedFiles = [];
|
final List<String> deletedFiles = [];
|
||||||
@@ -255,34 +253,34 @@ class _FakeGameInstallationService extends GameInstallationService {
|
|||||||
final Map<String, String> computedHashesByPath = {};
|
final Map<String, String> computedHashesByPath = {};
|
||||||
final Map<String, int?> fileSizesByPath = {};
|
final Map<String, int?> fileSizesByPath = {};
|
||||||
|
|
||||||
@override
|
// @override
|
||||||
Future<ClientInstallationSnapshot> scanInstallation(
|
// Future<ClientInstallationSnapshot> scanInstallation(
|
||||||
String installationDir, {
|
// String installationDir, {
|
||||||
InstallationScanProgressCallback? onProgress,
|
// InstallationScanProgressCallback? onProgress,
|
||||||
FutureOr<bool> Function()? isCancelled,
|
// FutureOr<bool> Function()? isCancelled,
|
||||||
}) async {
|
// }) async {
|
||||||
final snapshot = _snapshots.removeFirst();
|
// final snapshot = _snapshots.removeFirst();
|
||||||
var processedFiles = 0;
|
// var processedFiles = 0;
|
||||||
var processedBytes = 0;
|
// var processedBytes = 0;
|
||||||
|
|
||||||
for (final file in snapshot.files) {
|
// for (final file in snapshot.files) {
|
||||||
processedFiles += 1;
|
// processedFiles += 1;
|
||||||
processedBytes += file.size;
|
// processedBytes += file.size;
|
||||||
onProgress?.call(
|
// onProgress?.call(
|
||||||
DownloadProgress(
|
// DownloadProgress(
|
||||||
speed: 0,
|
// speed: 0,
|
||||||
downloaded: processedBytes,
|
// downloaded: processedBytes,
|
||||||
total: snapshot.files.fold<int>(0, (sum, item) => sum + item.size),
|
// total: snapshot.files.fold<int>(0, (sum, item) => sum + item.size),
|
||||||
eta: Duration.zero,
|
// eta: Duration.zero,
|
||||||
),
|
// ),
|
||||||
file.path,
|
// file.path,
|
||||||
processedFiles,
|
// processedFiles,
|
||||||
snapshot.files.length,
|
// snapshot.files.length,
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
return snapshot;
|
// return snapshot;
|
||||||
}
|
// }
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteRelativeFile(
|
Future<void> deleteRelativeFile(
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:moonwell_launcher/config.dart';
|
// import 'package:moonwell_launcher/config.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/domain/entities/client_hash_entry.dart';
|
// import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.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_game_ticket.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('GameInstallationService', () {
|
group('GameInstallationService', () {
|
||||||
@@ -48,37 +49,37 @@ void main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('scanInstallation ignores excluded directories', () async {
|
// test('scanInstallation ignores excluded directories', () async {
|
||||||
final snapshot = await service.scanInstallation(rootDirectory.path);
|
// final snapshot = await service.scanInstallation(rootDirectory.path);
|
||||||
final paths = snapshot.files.map((file) => file.path).toList()..sort();
|
// final paths = snapshot.files.map((file) => file.path).toList()..sort();
|
||||||
|
|
||||||
expect(paths, ['Data/common.MPQ', 'Wow.exe']);
|
// expect(paths, ['Data/common.MPQ', 'Wow.exe']);
|
||||||
expect(snapshot.buildHash, computeBuildHash(snapshot.files));
|
// expect(snapshot.buildHash, computeBuildHash(snapshot.files));
|
||||||
});
|
// });
|
||||||
|
|
||||||
test(
|
// test(
|
||||||
'scanInstallation creates launcher hash cache outside verification set',
|
// 'scanInstallation creates launcher hash cache outside verification set',
|
||||||
() async {
|
// () async {
|
||||||
await service.scanInstallation(rootDirectory.path);
|
// await service.scanInstallation(rootDirectory.path);
|
||||||
|
|
||||||
final cacheFile = File(service.getHashCachePath(rootDirectory.path));
|
// final cacheFile = File(service.getHashCachePath(rootDirectory.path));
|
||||||
expect(await cacheFile.exists(), isTrue);
|
// expect(await cacheFile.exists(), isTrue);
|
||||||
|
|
||||||
final secondSnapshot = await service.scanInstallation(
|
// final secondSnapshot = await service.scanInstallation(
|
||||||
rootDirectory.path,
|
// rootDirectory.path,
|
||||||
);
|
// );
|
||||||
final paths = secondSnapshot.files.map((file) => file.path).toList()
|
// final paths = secondSnapshot.files.map((file) => file.path).toList()
|
||||||
..sort();
|
// ..sort();
|
||||||
|
|
||||||
expect(paths, ['Data/common.MPQ', 'Wow.exe']);
|
// expect(paths, ['Data/common.MPQ', 'Wow.exe']);
|
||||||
expect(
|
// expect(
|
||||||
await Directory(
|
// await Directory(
|
||||||
'${rootDirectory.path}${Platform.pathSeparator}${Config.launcherMetadataDirectoryName}',
|
// '${rootDirectory.path}${Platform.pathSeparator}${Config.launcherMetadataDirectoryName}',
|
||||||
).exists(),
|
// ).exists(),
|
||||||
isTrue,
|
// isTrue,
|
||||||
);
|
// );
|
||||||
},
|
// },
|
||||||
);
|
// );
|
||||||
|
|
||||||
test('clearCache recreates an empty cache directory', () async {
|
test('clearCache recreates an empty cache directory', () async {
|
||||||
await service.clearCache(rootDirectory.path);
|
await service.clearCache(rootDirectory.path);
|
||||||
@@ -91,6 +92,30 @@ void main() {
|
|||||||
expect(await cacheDirectory.list().isEmpty, isTrue);
|
expect(await cacheDirectory.list().isEmpty, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('removeSavedAccountName removes accountName lines only', () async {
|
||||||
|
final configFile = File(
|
||||||
|
'${rootDirectory.path}${Platform.pathSeparator}WTF${Platform.pathSeparator}Config.wtf',
|
||||||
|
);
|
||||||
|
await configFile.parent.create(recursive: true);
|
||||||
|
await configFile.writeAsString(
|
||||||
|
'SET gxWindow "1"\r\n'
|
||||||
|
'SET accountName "admin#&|𞉀#&|�"\r\n'
|
||||||
|
'set ACCOUNTNAME "another account"\r\n'
|
||||||
|
'SET locale "ruRU"\r\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.removeSavedAccountName(rootDirectory.path);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await configFile.readAsString(),
|
||||||
|
'SET gxWindow "1"\r\nSET locale "ruRU"\r\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeSavedAccountName ignores a missing Config.wtf', () async {
|
||||||
|
await service.removeSavedAccountName(rootDirectory.path);
|
||||||
|
});
|
||||||
|
|
||||||
test('ensureParentDirectoryExists creates nested directories', () async {
|
test('ensureParentDirectoryExists creates nested directories', () async {
|
||||||
final tempPath =
|
final tempPath =
|
||||||
'${rootDirectory.path}${Platform.pathSeparator}Data${Platform.pathSeparator}patches${Platform.pathSeparator}common-2.MPQ.moonwell.part';
|
'${rootDirectory.path}${Platform.pathSeparator}Data${Platform.pathSeparator}patches${Platform.pathSeparator}common-2.MPQ.moonwell.part';
|
||||||
@@ -125,5 +150,45 @@ void main() {
|
|||||||
throwsA(isA<LauncherSyncException>()),
|
throwsA(isA<LauncherSyncException>()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'launchGame passes authorization only through child environment',
|
||||||
|
() async {
|
||||||
|
final recordingService = _RecordingGameInstallationService();
|
||||||
|
final authorization = LauncherGameTicket(
|
||||||
|
account: 'PLAYERONE',
|
||||||
|
ticket: 'A1B2C3D4E5F6G7H8',
|
||||||
|
expiresAt: DateTime.now().toUtc().add(const Duration(seconds: 60)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await recordingService.launchGame(
|
||||||
|
rootDirectory.path,
|
||||||
|
authorization: authorization,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(recordingService.arguments, isEmpty);
|
||||||
|
expect(recordingService.environment, <String, String>{
|
||||||
|
'MOONWELL_LAUNCH_ACCOUNT': 'PLAYERONE',
|
||||||
|
'MOONWELL_LAUNCH_TICKET': 'A1B2C3D4E5F6G7H8',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class _RecordingGameInstallationService extends GameInstallationService {
|
||||||
|
Map<String, String>? environment;
|
||||||
|
List<String>? arguments;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<GameProcessHandle> startGameProcess({
|
||||||
|
required String executablePath,
|
||||||
|
required List<String> arguments,
|
||||||
|
required String workingDirectory,
|
||||||
|
required Map<String, String> environment,
|
||||||
|
}) async {
|
||||||
|
this.arguments = List<String>.from(arguments);
|
||||||
|
this.environment = Map<String, String>.from(environment);
|
||||||
|
return GameProcessHandle(pid: 42, exitCode: Future<int>.value(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,61 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('LauncherGameTicket', () {
|
||||||
|
test('parses the launcher authorization contract', () {
|
||||||
|
final ticket = LauncherGameTicket.fromJson(<String, Object?>{
|
||||||
|
'account': 'PLAYERONE',
|
||||||
|
'ticket': 'A1B2C3D4E5F6G7H8',
|
||||||
|
'expires_at': '2026-08-16T12:01:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ticket.account, 'PLAYERONE');
|
||||||
|
expect(ticket.ticket, 'A1B2C3D4E5F6G7H8');
|
||||||
|
expect(ticket.expiresAt, DateTime.utc(2026, 8, 16, 12, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects tickets outside the native client format', () {
|
||||||
|
expect(
|
||||||
|
() => LauncherGameTicket.fromJson(<String, Object?>{
|
||||||
|
'account': 'PLAYERONE',
|
||||||
|
'ticket': 'lowercase-ticket',
|
||||||
|
'expires_at': '2026-08-16T12:01:00Z',
|
||||||
|
}),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not normalize credentials from the API', () {
|
||||||
|
expect(
|
||||||
|
() => LauncherGameTicket.fromJson(<String, Object?>{
|
||||||
|
'account': 'PLAYER ',
|
||||||
|
'ticket': ' A1B2C3D4E5F6G7H8',
|
||||||
|
'expires_at': '2026-08-16T12:01:00Z',
|
||||||
|
}),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requires enough validity to start the game', () {
|
||||||
|
final ticket = LauncherGameTicket(
|
||||||
|
account: 'PLAYERONE',
|
||||||
|
ticket: 'A1B2C3D4E5F6G7H8',
|
||||||
|
expiresAt: DateTime.utc(2026, 8, 16, 12, 0, 4),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ticket.isUsable(now: DateTime.utc(2026, 8, 16, 12)), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-string payload fields with a format exception', () {
|
||||||
|
expect(
|
||||||
|
() => LauncherGameTicket.fromJson(<String, dynamic>{
|
||||||
|
'account': 42,
|
||||||
|
'ticket': 'ABCDEFGH12345678',
|
||||||
|
'expires_at': '2026-08-16T20:00:00Z',
|
||||||
|
}),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,713 @@
|
|||||||
|
<#
|
||||||
|
.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 EnvFile
|
||||||
|
Local dotenv file used to fill environment variables that are not already set.
|
||||||
|
|
||||||
|
.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]$EnvFile = '.env',
|
||||||
|
[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 Import-DotEnv {
|
||||||
|
param([Parameter(Mandatory = $true)][string]$Path)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedNames = @(
|
||||||
|
'AWS_ACCESS_KEY_ID',
|
||||||
|
'AWS_SECRET_ACCESS_KEY',
|
||||||
|
'AWS_DEFAULT_REGION',
|
||||||
|
'AWS_BUCKET',
|
||||||
|
'AWS_ENDPOINT',
|
||||||
|
'AWS_USE_PATH_STYLE_ENDPOINT',
|
||||||
|
'LAUNCHER_AUTH_KEY'
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($line in Get-Content -LiteralPath $Path) {
|
||||||
|
$trimmed = $line.Trim()
|
||||||
|
if (
|
||||||
|
[string]::IsNullOrWhiteSpace($trimmed) -or
|
||||||
|
$trimmed.StartsWith('#')
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$separator = $trimmed.IndexOf('=')
|
||||||
|
if ($separator -le 0) {
|
||||||
|
throw "Invalid dotenv line in ${Path}: $line"
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $trimmed.Substring(0, $separator).Trim()
|
||||||
|
if ($allowedNames -notcontains $name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $trimmed.Substring($separator + 1).Trim()
|
||||||
|
if (
|
||||||
|
$value.Length -ge 2 -and
|
||||||
|
(
|
||||||
|
($value.StartsWith('"') -and $value.EndsWith('"')) -or
|
||||||
|
($value.StartsWith("'") -and $value.EndsWith("'"))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
$value = $value.Substring(1, $value.Length - 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = [Environment]::GetEnvironmentVariable($name)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($existing)) {
|
||||||
|
[Environment]::SetEnvironmentVariable(
|
||||||
|
$name,
|
||||||
|
$value,
|
||||||
|
[EnvironmentVariableTarget]::Process
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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]$BuildVersion,
|
||||||
|
[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,
|
||||||
|
$BuildVersion
|
||||||
|
)
|
||||||
|
$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
|
||||||
|
|
||||||
|
$resolvedEnvFile = if ([IO.Path]::IsPathRooted($EnvFile)) {
|
||||||
|
$EnvFile
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Join-Path $repositoryRoot $EnvFile
|
||||||
|
}
|
||||||
|
Import-DotEnv -Path $resolvedEnvFile
|
||||||
|
|
||||||
|
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
|
||||||
|
$publishedReleaseVersion = $publishedVersion.Split('+')[0]
|
||||||
|
if (
|
||||||
|
-not [string]::IsNullOrWhiteSpace($publishedVersion) -and
|
||||||
|
[version]$version -le [version]$publishedReleaseVersion
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
$appcastBuildVersion = "$version+$buildNumber"
|
||||||
|
$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 `
|
||||||
|
-BuildVersion $appcastBuildVersion `
|
||||||
|
-Notes $ReleaseNotes `
|
||||||
|
-InstallerUrl $installerUrl `
|
||||||
|
-Signature $signature `
|
||||||
|
-Length $installer.Length
|
||||||
|
|
||||||
|
[xml]$generatedAppcast = Get-Content `
|
||||||
|
-LiteralPath $appcastPath `
|
||||||
|
-Raw `
|
||||||
|
-Encoding UTF8
|
||||||
|
if ((Get-AppcastVersion -Path $appcastPath) -ne $appcastBuildVersion) {
|
||||||
|
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
|
||||||
|
$uploadHelper = Join-Path `
|
||||||
|
$repositoryRoot `
|
||||||
|
'tool\upload_launcher_release.py'
|
||||||
|
if (-not (Test-Path -LiteralPath $uploadHelper -PathType Leaf)) {
|
||||||
|
throw "S3 upload helper was not found: $uploadHelper"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Invoke-RequiredCommand -Command $python -Arguments @($uploadHelper)
|
||||||
|
}
|
||||||
|
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 $appcastBuildVersion -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Upload a MoonWell Launcher installer to S3-compatible storage.
|
||||||
|
|
||||||
|
Release metadata is supplied by ``deploy_launcher.ps1`` through environment
|
||||||
|
variables. A real Python file avoids Windows PowerShell quoting problems that
|
||||||
|
occur when a multiline program is passed to ``python -c``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def required_environment_value(name: str) -> str:
|
||||||
|
value = os.environ.get(name, "").strip()
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError(f"Required environment variable is missing: {name}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
release_file = Path(required_environment_value("MOONWELL_RELEASE_FILE"))
|
||||||
|
endpoint = required_environment_value("MOONWELL_RELEASE_ENDPOINT")
|
||||||
|
bucket = required_environment_value("MOONWELL_RELEASE_BUCKET")
|
||||||
|
object_key = required_environment_value("MOONWELL_RELEASE_KEY")
|
||||||
|
version = required_environment_value("MOONWELL_RELEASE_VERSION")
|
||||||
|
sha256 = required_environment_value("MOONWELL_RELEASE_SHA256")
|
||||||
|
|
||||||
|
if not release_file.is_file():
|
||||||
|
raise FileNotFoundError(f"Installer does not exist: {release_file}")
|
||||||
|
|
||||||
|
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=endpoint,
|
||||||
|
region_name=os.getenv("AWS_DEFAULT_REGION", "ru-central1"),
|
||||||
|
config=Config(s3={"addressing_style": addressing_style}),
|
||||||
|
)
|
||||||
|
|
||||||
|
s3.upload_file(
|
||||||
|
str(release_file),
|
||||||
|
bucket,
|
||||||
|
object_key,
|
||||||
|
ExtraArgs={
|
||||||
|
"ACL": "public-read",
|
||||||
|
"ContentType": "application/x-msdownload",
|
||||||
|
"CacheControl": "no-cache, max-age=0",
|
||||||
|
"Metadata": {
|
||||||
|
"launcher-version": version,
|
||||||
|
"sha256": sha256,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = s3.head_object(Bucket=bucket, Key=object_key)
|
||||||
|
local_size = release_file.stat().st_size
|
||||||
|
remote_size = int(response["ContentLength"])
|
||||||
|
if remote_size != local_size:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Uploaded object size mismatch: local={local_size}, "
|
||||||
|
f"remote={remote_size}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("S3_VERSION_ID=" + str(response.get("VersionId", "")))
|
||||||
|
print("S3_CONTENT_LENGTH=" + str(remote_size))
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX")
|
|||||||
target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
|
target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
|
||||||
target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
|
target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
|
||||||
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
|
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
|
||||||
|
target_link_options(
|
||||||
|
${BINARY_NAME}
|
||||||
|
PRIVATE
|
||||||
|
"/MANIFESTUAC:level='requireAdministrator' uiAccess='false'"
|
||||||
|
)
|
||||||
|
|
||||||
# Run the Flutter tool portions of the build. This must not be removed.
|
# Run the Flutter tool portions of the build. This must not be removed.
|
||||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||||
|
|||||||
@@ -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