15 Commits

65 changed files with 4234 additions and 708 deletions
+7
View File
@@ -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
+5
View File
@@ -49,3 +49,8 @@ app.*.map.json
# Local launcher update signing material
.moonwell_signing/
# Local production deployment credentials
.env
.env.*
!.env.example
+3 -2
View File
@@ -1,3 +1,4 @@
{
"dart.flutterSdkPath": ".fvm/versions/stable"
}
"dart.flutterSdkPath": ".fvm/versions/stable",
"cmake.sourceDirectory": "C:/Users/sindo/moonwell_launcher/windows"
}
+50 -17
View File
@@ -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
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:
- logging in to a player's MoonWell account
- registering new MoonWell game accounts
- downloading and synchronizing client files
- 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
@@ -30,20 +41,20 @@ and all Dart and Flutter commands are run through FVM.
fvm flutter pub get
```
Verify the configured SDK with `fvm flutter --version`.
Verify the configured SDK with `flutter --version`.
## Configuration
Set the launcher Web API base URL at compile time:
```powershell
--dart-define=MOONWELL_API_BASE_URL=https://host
--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online
```
Set the public AppCast feed URL to enable Windows launcher self-updates:
```powershell
--dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml
--dart-define=MOONWELL_APPCAST_URL=https://moon-well.online/appcast.xml
```
The updater is disabled when `MOONWELL_APPCAST_URL` is omitted. Production
@@ -52,7 +63,7 @@ feeds must use HTTPS.
Example run command:
```powershell
fvm flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://host --dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml
flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://moon-well.online --dart-define=MOONWELL_APPCAST_URL=https://moon-well.online/appcast.xml
```
## Development
@@ -61,8 +72,8 @@ Run the component catalog from its standalone workspace:
```powershell
cd widgetbook
fvm dart run build_runner build
fvm flutter run -d windows
dart run build_runner build
flutter run -d windows
```
See `docs/widgetbook.md` for the required use-case naming, knobs, callback,
@@ -71,13 +82,13 @@ asset, and coverage conventions.
Regenerate dependency injection after changing injectable services:
```powershell
fvm dart run build_runner build --delete-conflicting-outputs
dart run build_runner build --delete-conflicting-outputs
```
Run static analysis:
```powershell
fvm flutter analyze
flutter analyze
```
See `docs/linting.md` for the full format, analyze, test, and Lefthook
@@ -86,13 +97,30 @@ pre-commit workflow.
Build the Windows launcher:
```powershell
fvm flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host --dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml
flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://moon-well.online --dart-define=MOONWELL_APPCAST_URL=https://moon-well.online/appcast.xml
```
The Inno Setup installer script lives at `installer/moonwell_launcher.iss`.
See `docs/launcher_self_update.md` for the AppCast format, signing keys, and
manual release procedure.
Run a safe local production-release rehearsal:
```powershell
.\tool\deploy_launcher.ps1 `
-DryRun `
-ExpectedVersion 1.0.2 `
-ReleaseNotes "Исправления и улучшения MoonWell Launcher."
```
Remove `-DryRun` only after reviewing the generated installer and AppCast.
See `docs/production_deploy.md` for required environment variables, production
defaults, safety checks, and failure recovery.
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
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:
1. authenticate against the MoonWell Web API
2. fetch the client manifest
3. scan the selected installation directory
4. compare local files to the manifest
5. remove stale files outside ignored directories
6. download missing or changed files to temporary part files
7. verify size and SHA-256 before replacing client files
8. launch `Wow.exe` from the selected client directory
2. fetch the client manifest and automatically synchronize a saved client
installation
3. refresh the manifest every five minutes while the launcher is open and
automatically synchronize when the server build changes
4. scan the selected installation directory
5. compare local files to the manifest
6. remove stale files outside ignored directories
7. download missing or changed files to temporary part files
8. verify size and SHA-256 before replacing client files
9. 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.
@@ -123,4 +155,5 @@ See `docs/launcher_web_api_spec.md` for the API and sync contract.
upgrade feasibility notes
- `docs/launcher_web_api_spec.md`: current launcher Web API and sync behavior
- `docs/launcher_self_update.md`: Windows self-update and release operations
- `docs/production_deploy.md`: automated production launcher deployment
- `docs/widgetbook.md`: Widgetbook use-case generation and catalog conventions
+14
View File
@@ -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

+39 -12
View File
@@ -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
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
Pass the public feed URL at build time:
```powershell
fvm flutter build windows `
--dart-define=MOONWELL_API_BASE_URL=https://host `
--dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml
flutter build windows `
--dart-define=MOONWELL_API_BASE_URL=https://moon-well.online `
--dart-define=MOONWELL_APPCAST_URL=https://moon-well.online/appcast.xml
```
The feed URL must be an absolute HTTPS URL. Omit
@@ -45,8 +50,11 @@ certificate would not establish trust on player machines.
## Version and AppCast Contract
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
compares the Windows product version. Pass the same `X.Y.Z` value to Inno Setup:
build-number-only change such as `1.0.0+2` is not sufficient for a public
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
ISCC.exe /DMyAppVersion=1.1.0 installer\moonwell_launcher.iss
@@ -67,11 +75,11 @@ Use a public AppCast with absolute URLs:
<language>ru</language>
<item>
<title>MoonWell Launcher 1.1.0</title>
<sparkle:releaseNotesLink>https://host/launcher/updates/1.1.0/release-notes.html</sparkle:releaseNotesLink>
<pubDate>Sun, 21 Jun 2026 12:00:00 +0400</pubDate>
<enclosure
url="https://host/launcher/updates/1.1.0/moonwell_launcher_1.1.0_windows_setup.exe"
sparkle:version="1.1.0"
url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe"
sparkle:version="1.1.0+3"
sparkle:shortVersionString="1.1.0"
sparkle:os="windows"
sparkle:dsaSignature="SIGNATURE_FROM_SIGN_UPDATE"
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
invokes WinSparkle's Windows signing utility.
The recommended production workflow is the automated
[`tool/deploy_launcher.ps1`](../tool/deploy_launcher.ps1) script. See
[`production_deploy.md`](production_deploy.md) for prerequisites, environment
variables, dry-run usage, deployment, and failure recovery. The steps below are
the manual fallback and describe the same release order.
1. Update `pubspec.yaml` to the new `X.Y.Z+N` version.
2. Run `fvm flutter pub get`, formatting, analysis, and all tests.
2. Run `flutter pub get`, formatting, analysis, and all tests.
3. Build Windows with both production `--dart-define` values.
4. Compile the installer with `ISCC.exe /DMyAppVersion=X.Y.Z`.
5. Make the private key available outside the repository and sign the artifact:
```powershell
fvm dart run auto_updater:sign_update `
dart run auto_updater:sign_update `
build\installer\moonwell_launcher_X.Y.Z_windows_setup.exe `
C:\secure-path\dsa_priv.pem
```
@@ -109,10 +123,23 @@ invokes WinSparkle's Windows signing utility.
(Get-Item build\installer\moonwell_launcher_X.Y.Z_windows_setup.exe).Length
```
7. Upload the versioned installer and release notes first.
7. Upload the installer to the root of the `warcraft-client` bucket as
`moonwell_launcher_setup.exe`. The object must be publicly readable.
8. Download the hosted installer, verify its byte length, and test its DSA
signature before changing the feed.
9. Publish `appcast.xml` last so clients never observe an incomplete release.
9. Set `LAUNCHER_AUTH_KEY` in the release environment and publish
`appcast.xml` last:
```powershell
curl.exe `
-X POST "https://moon-well.online/api/service/update-appcast" `
-H "Auth: $env:LAUNCHER_AUTH_KEY" `
-H "Content-Type: application/xml" `
--data-binary "@appcast.xml"
```
10. Fetch `https://moon-well.online/appcast.xml` and verify the published
version, installer URL, byte length, and DSA signature.
Keep the preceding installer available for rollback and support. Rollback is a
new release with a product version higher than the faulty release; WinSparkle
+73 -8
View File
@@ -21,17 +21,31 @@ The launcher API base URL is provided via:
Current integration mode is compile-time:
- `fvm flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://host`
- `fvm flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host`
- `flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://host`
- `flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host`
## API Flow
The launcher uses these endpoints from `openapi.json`:
1. `POST /api/launcher/login`
2. `GET /api/launcher/manifest`
3. `GET /api/launcher/download/{path}`
4. `GET /api/launcher/news`
2. `POST /api/launcher/register`
3. `GET /api/launcher/manifest`
4. `GET /api/launcher/download/{path}`
5. `GET /api/launcher/news`
6. `GET /api/launcher/realms`
7. `GET /api/launcher/account`
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:
@@ -41,6 +55,11 @@ Authentication sequence:
4. Successful login persists `LauncherSession` locally.
5. On next launcher start, saved session is reused to fetch manifest again.
6. `LauncherSession` and `ClientManifest` are passed into the home screen.
7. If an installation directory is saved, synchronization starts
automatically with the fetched manifest.
8. While the launcher is open, it refreshes the manifest every five minutes.
9. A server build hash that differs from the verified local build hash starts
synchronization automatically.
Logout sequence:
@@ -65,6 +84,41 @@ News sequence:
2. The response payload is read from the top-level `data` array.
3. Each news item provides `id`, `title`, `body`, optional `image_url`, and `created_at`.
4. News failures do not block patching or play flow; the launcher shows a local error state only inside the news panel.
5. Pressing a news item opens `<MOONWELL_API_BASE_URL>/news/{id}` in the
system browser.
Realm status sequence:
1. After the authenticated home screen opens, the launcher requests
`GET /api/launcher/realms`.
2. The first item in the top-level `data` array supplies the realm name,
online state, and game build shown in the sync panel.
3. Realm information is refreshed every minute while the launcher remains
open.
4. A failed refresh keeps the last known realm state and is retried on the next
interval.
Account sequence:
1. After the authenticated home screen opens, the launcher requests
`GET /api/launcher/account` with bearer authentication.
2. Only `username` is stored in launcher UI state and displayed in the account
menu.
3. The response `balance` field is intentionally ignored and never displayed.
4. Account metadata failures do not block patching or launching the game.
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
@@ -190,9 +244,18 @@ Pause is implemented as cooperative cancellation:
When the user presses `Play`:
1. launcher resolves `<install-root>/Wow.exe`
2. launcher clears `<install-root>/Cache`
3. launcher starts `Wow.exe` with working directory set to installation root
1. launcher removes every `SET accountName ...` line from
`<install-root>/WTF/Config.wtf` when that file exists
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.
@@ -202,12 +265,14 @@ The launcher surfaces errors for:
- invalid API configuration
- authentication failure
- registration validation or server failure
- manifest load failure
- path traversal attempts
- checksum mismatch after download
- missing downloaded temp files before verification
- final verification mismatch after update
- missing `Wow.exe`
- invalid, expired, or unavailable game ticket
## Tested Invariants
+174
View File
@@ -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.
+6
View File
@@ -44,5 +44,11 @@ Source: "{#MyBuildDir}\*"; DestDir: "{app}"; Excludes: "moonwell_launcher.exe";
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
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]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
+171 -58
View File
@@ -1,22 +1,41 @@
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/theme/moonwell_design_system.dart';
enum MwAuthenticationMode { login, registration }
class MwAuthenticationForm extends StatefulWidget {
const MwAuthenticationForm({
super.key,
required this.usernameController,
required this.passwordController,
required this.onSubmit,
this.emailController,
this.passwordConfirmationController,
this.inviteCodeController,
this.mode = MwAuthenticationMode.login,
this.onModeChanged,
this.termsAccepted = false,
this.onTermsChanged,
this.loading = false,
this.error,
this.success,
});
final TextEditingController usernameController;
final TextEditingController passwordController;
final TextEditingController? emailController;
final TextEditingController? passwordConfirmationController;
final TextEditingController? inviteCodeController;
final VoidCallback onSubmit;
final MwAuthenticationMode mode;
final ValueChanged<MwAuthenticationMode>? onModeChanged;
final bool termsAccepted;
final ValueChanged<bool>? onTermsChanged;
final bool loading;
final String? error;
final String? success;
@override
State<MwAuthenticationForm> createState() => _MwAuthenticationFormState();
@@ -28,6 +47,8 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
final registering = widget.mode == MwAuthenticationMode.registration;
final launcherVersion = LauncherVersionScope.of(context);
return AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -35,13 +56,13 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
const MwBrandMark(width: 210, compact: true),
const SizedBox(height: 28),
Text(
'ВХОД',
registering ? 'РЕГИСТРАЦИЯ' : 'ВХОД',
style: TextStyle(color: accent, fontSize: 10, letterSpacing: 2.4),
),
const SizedBox(height: 10),
const Text(
'С возвращением',
style: TextStyle(
Text(
registering ? 'Создать аккаунт' : 'С возвращением',
style: const TextStyle(
fontFamily: 'Cinzel',
color: Color(0xFFECE4D0),
fontSize: 30,
@@ -49,9 +70,11 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
),
),
const SizedBox(height: 7),
const Text(
'Войди в свой аккаунт, чтобы продолжить путь.',
style: TextStyle(
Text(
registering
? 'Зарегистрируй игровой аккаунт MoonWell.'
: 'Войди в свой аккаунт, чтобы продолжить путь.',
style: const TextStyle(
fontFamily: 'Cormorant Garamond',
fontStyle: FontStyle.italic,
color: Color(0x80ECE4D0),
@@ -61,12 +84,30 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
const SizedBox(height: 22),
Row(
children: [
_Tab(label: 'ВХОД', selected: true, accent: accent),
_Tab(label: 'РЕГИСТРАЦИЯ', accent: accent),
_Tab(
label: 'ВХОД',
selected: !registering,
accent: accent,
onTap: widget.loading
? null
: () => widget.onModeChanged?.call(
MwAuthenticationMode.login,
),
),
_Tab(
label: 'РЕГИСТРАЦИЯ',
selected: registering,
accent: accent,
onTap: widget.loading
? null
: () => widget.onModeChanged?.call(
MwAuthenticationMode.registration,
),
),
],
),
const SizedBox(height: 18),
_FieldLabel(text: 'ЛОГИН ИЛИ EMAIL'),
_FieldLabel(text: registering ? 'ЛОГИН' : 'ЛОГИН ИЛИ EMAIL'),
const SizedBox(height: 7),
TextField(
controller: widget.usernameController,
@@ -75,6 +116,19 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
autofillHints: const [AutofillHints.username],
decoration: const InputDecoration(hintText: 'Aranthel'),
),
if (registering) ...[
const SizedBox(height: 14),
const _FieldLabel(text: 'EMAIL'),
const SizedBox(height: 7),
TextField(
controller: widget.emailController,
enabled: !widget.loading,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(hintText: 'player@example.com'),
),
],
const SizedBox(height: 14),
_FieldLabel(text: 'ПАРОЛЬ'),
const SizedBox(height: 7),
@@ -82,10 +136,35 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
controller: widget.passwordController,
enabled: !widget.loading,
obscureText: true,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
onSubmitted: (_) => widget.onSubmit(),
textInputAction: registering
? TextInputAction.next
: TextInputAction.done,
autofillHints: [
registering ? AutofillHints.newPassword : AutofillHints.password,
],
onSubmitted: registering ? null : (_) => widget.onSubmit(),
),
if (registering) ...[
const SizedBox(height: 14),
const _FieldLabel(text: 'ПОВТОРИТЕ ПАРОЛЬ'),
const SizedBox(height: 7),
TextField(
controller: widget.passwordConfirmationController,
enabled: !widget.loading,
obscureText: true,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.newPassword],
),
const SizedBox(height: 14),
const _FieldLabel(text: 'ИНВАЙТ-КОД (НЕОБЯЗАТЕЛЬНО)'),
const SizedBox(height: 7),
TextField(
controller: widget.inviteCodeController,
enabled: !widget.loading,
textInputAction: TextInputAction.done,
onSubmitted: (_) => widget.onSubmit(),
),
],
if (widget.error != null) ...[
const SizedBox(height: 10),
Text(
@@ -93,30 +172,61 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
style: const TextStyle(color: Color(0xFFD76A78), fontSize: 12),
),
],
if (widget.success != null) ...[
const SizedBox(height: 10),
Text(
widget.success!,
style: const TextStyle(color: Color(0xFF6FC28A), fontSize: 12),
),
],
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 24,
child: Checkbox(
value: _remember,
onChanged: widget.loading
? null
: (value) => setState(() => _remember = value ?? false),
if (registering)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 24,
child: Checkbox(
value: widget.termsAccepted,
onChanged: widget.loading
? null
: (value) =>
widget.onTermsChanged?.call(value ?? false),
),
),
),
const SizedBox(width: 8),
const Text(
'Запомнить меня',
style: TextStyle(color: Color(0x9AECE4D0), fontSize: 11),
),
const Spacer(),
Text(
'Забыли пароль?',
style: TextStyle(color: accent, fontSize: 11),
),
],
),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Я принимаю правила сервера и пользовательское соглашение',
style: TextStyle(color: Color(0x9AECE4D0), fontSize: 11),
),
),
],
)
else
Row(
children: [
SizedBox(
width: 24,
child: Checkbox(
value: _remember,
onChanged: widget.loading
? null
: (value) => setState(() => _remember = value ?? false),
),
),
const SizedBox(width: 8),
const Text(
'Запомнить меня',
style: TextStyle(color: Color(0x9AECE4D0), fontSize: 11),
),
const Spacer(),
Text(
'Забыли пароль?',
style: TextStyle(color: accent, fontSize: 11),
),
],
),
const SizedBox(height: 18),
SizedBox(
height: 54,
@@ -134,27 +244,27 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Row(
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'ВОЙТИ',
style: TextStyle(
registering ? 'СОЗДАТЬ АККАУНТ' : 'ВОЙТИ',
style: const TextStyle(
fontFamily: 'Cinzel',
letterSpacing: 2.5,
),
),
SizedBox(width: 14),
Icon(Icons.arrow_forward, size: 18),
const SizedBox(width: 14),
const Icon(Icons.arrow_forward, size: 18),
],
),
),
),
const SizedBox(height: 28),
const Center(
Center(
child: Text(
'MOONWELL · V 1.0.0 · LOGIN.MOONWELL.GG',
style: TextStyle(
'MOONWELL · V $launcherVersion · PLAY.MOON-WELL.ONLINE',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: Color(0x52ECE4D0),
fontSize: 8,
@@ -187,30 +297,33 @@ class _Tab extends StatelessWidget {
const _Tab({
required this.label,
required this.accent,
this.onTap,
this.selected = false,
});
final String label;
final Color accent;
final VoidCallback? onTap;
final bool selected;
@override
Widget build(BuildContext context) => Expanded(
child: Container(
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: selected ? accent : const Color(0x247FB8D4),
child: InkWell(
onTap: onTap,
child: Container(
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: selected ? accent : Colors.transparent),
),
),
),
child: Text(
label,
style: TextStyle(
fontFamily: 'Cinzel',
color: selected ? const Color(0xFFECE4D0) : const Color(0x80ECE4D0),
fontSize: 9,
letterSpacing: 1.5,
child: Text(
label,
style: TextStyle(
fontFamily: 'Cinzel',
color: selected ? const Color(0xFFECE4D0) : const Color(0x80ECE4D0),
fontSize: 9,
letterSpacing: 1.5,
),
),
),
),
+211 -205
View File
@@ -1,4 +1,5 @@
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/theme/moonwell_design_system.dart';
@@ -99,7 +100,7 @@ class MwGameTile extends StatelessWidget {
],
),
),
if (!enabled)
if (!enabled && subtitle == null)
const Text(
'SOON',
style: TextStyle(
@@ -121,13 +122,24 @@ class MwGameTile extends StatelessWidget {
}
class MwGameRail extends StatelessWidget {
const MwGameRail({super.key, this.compact = false});
const MwGameRail({
super.key,
this.compact = false,
this.onSettings,
this.username,
});
final bool compact;
final VoidCallback? onSettings;
final String? username;
@override
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
final normalizedUsername = username?.trim() ?? '';
final userInitial = normalizedUsername.isEmpty
? '?'
: normalizedUsername.characters.first.toUpperCase();
return Container(
padding: const EdgeInsets.fromLTRB(14, 48, 14, 30),
decoration: const BoxDecoration(
@@ -154,7 +166,10 @@ class MwGameRail extends StatelessWidget {
BoxShadow(color: accent.withAlpha(70), blurRadius: 12),
],
),
child: const Text('A', style: TextStyle(fontFamily: 'Cinzel')),
child: Text(
userInitial,
style: const TextStyle(fontFamily: 'Cinzel'),
),
),
const SizedBox(height: 14),
const Divider(color: _border),
@@ -183,28 +198,13 @@ class MwGameRail extends StatelessWidget {
onPressed: () {},
),
const SizedBox(height: 8),
const MwGameTile(
MwGameTile(
title: 'MoonWell Karts',
subtitle: 'Closed Alpha',
subtitle: 'В разработке',
mark: 'K',
icon: Icons.sports_motorsports,
enabled: false,
),
const SizedBox(height: 8),
const MwGameTile(
title: 'Tides of Elune',
subtitle: 'В разработке',
mark: 'T',
icon: Icons.style,
enabled: false,
),
const SizedBox(height: 8),
const MwGameTile(
title: 'Project Verge',
subtitle: 'Анонс',
mark: 'V',
icon: Icons.auto_awesome,
enabled: false,
compact: compact,
),
const Spacer(),
const Divider(color: _border),
@@ -216,12 +216,15 @@ class MwGameRail extends StatelessWidget {
icon: const Icon(Icons.people_outline, size: 18),
),
const Spacer(),
Icon(
Icons.settings_outlined,
color: accent.withAlpha(150),
size: 18,
IconButton(
onPressed: onSettings,
tooltip: 'Настройки',
icon: Icon(
Icons.settings_outlined,
color: accent.withAlpha(150),
size: 18,
),
),
const SizedBox(width: 12),
],
),
],
@@ -236,11 +239,13 @@ class MwNewsItemData {
required this.body,
this.createdAt,
this.imageUrl,
this.onPressed,
});
final String title;
final String body;
final DateTime? createdAt;
final String? imageUrl;
final VoidCallback? onPressed;
}
class MwNewsItem extends StatelessWidget {
@@ -251,55 +256,64 @@ class MwNewsItem extends StatelessWidget {
Widget build(BuildContext context) {
final accent = MoonWellDesignTokens.of(context).accent;
final date = item.createdAt?.toLocal();
return Padding(
padding: const EdgeInsets.fromLTRB(0, 13, 12, 13),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
return Material(
color: Colors.transparent,
child: InkWell(
onTap: item.onPressed,
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 13, 12, 13),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
border: Border.all(color: accent),
borderRadius: BorderRadius.circular(3),
),
child: Text(
'НОВОСТЬ',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: accent,
fontSize: 8,
letterSpacing: 1.5,
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
border: Border.all(color: accent),
borderRadius: BorderRadius.circular(3),
),
child: Text(
'НОВОСТЬ',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: accent,
fontSize: 8,
letterSpacing: 1.5,
),
),
),
),
const Spacer(),
Text(
date == null
? 'СЕГОДНЯ'
: '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 9,
),
),
],
),
const Spacer(),
const SizedBox(height: 7),
Text(
date == null
? 'СЕГОДНЯ'
: '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}',
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 9,
fontFamily: 'Cormorant Garamond',
color: _ivory,
fontSize: 16,
height: 1.25,
),
),
],
),
const SizedBox(height: 7),
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Cormorant Garamond',
color: _ivory,
fontSize: 16,
height: 1.25,
),
),
],
),
),
);
}
@@ -337,8 +351,6 @@ class MwNewsList extends StatelessWidget {
child: Row(
children: [
_NewsTab(label: 'НОВОСТИ', selected: true, accent: accent),
_NewsTab(label: 'СОБЫТИЯ', accent: accent),
_NewsTab(label: 'ПАТЧИ', accent: accent),
],
),
),
@@ -353,7 +365,7 @@ class MwNewsList extends StatelessWidget {
child: Row(
children: [
const Text(
'MOONWELL.GG',
'MOON-WELL.ONLINE',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
@@ -455,6 +467,9 @@ class MwSyncActionArea extends StatelessWidget {
this.progress,
this.currentPath,
this.error,
this.realmName,
this.realmOnline,
this.realmGameBuild,
});
final MwSyncPresentation state;
final String status;
@@ -463,6 +478,9 @@ class MwSyncActionArea extends StatelessWidget {
final double? progress;
final String? currentPath;
final String? error;
final String? realmName;
final bool? realmOnline;
final int? realmGameBuild;
@override
Widget build(BuildContext context) {
@@ -477,6 +495,14 @@ class MwSyncActionArea extends StatelessWidget {
final value = ready || state == MwSyncPresentation.launched
? 1.0
: (progress ?? 0);
final realmStatus = switch (realmOnline) {
true => 'ONLINE',
false => 'OFFLINE',
null => 'STATUS UNKNOWN',
};
final realmMetadata = realmGameBuild != null && realmGameBuild! > 0
? '$realmStatus · BUILD $realmGameBuild'
: realmStatus;
final label = switch (state) {
MwSyncPresentation.install => 'УСТАНОВИТЬ',
MwSyncPresentation.paused => 'ПРОДОЛЖИТЬ',
@@ -503,26 +529,28 @@ class MwSyncActionArea extends StatelessWidget {
border: Border.all(color: _border),
borderRadius: BorderRadius.circular(6),
),
child: const Row(
child: Row(
children: [
_PulseDot(),
SizedBox(width: 12),
_PulseDot(online: realmOnline),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"ELUNE'S GRACE",
style: TextStyle(
(realmName?.trim().isNotEmpty ?? false)
? realmName!.toUpperCase()
: 'REALM UNAVAILABLE',
style: const TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 11,
letterSpacing: 1.5,
),
),
SizedBox(height: 3),
const SizedBox(height: 3),
Text(
'ONLINE · EU',
style: TextStyle(
realmMetadata,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
@@ -592,14 +620,20 @@ class MwSyncActionArea extends StatelessWidget {
),
),
),
Text(
ready ? 'Последняя проверка: только что' : '',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
if (ready)
const Flexible(
child: Text(
'Последняя проверка: только что',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
),
),
),
),
],
),
],
@@ -647,17 +681,27 @@ class MwSyncActionArea extends StatelessWidget {
}
class _PulseDot extends StatelessWidget {
const _PulseDot();
const _PulseDot({required this.online});
final bool? online;
@override
Widget build(BuildContext context) => Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF6FC28A),
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Color(0xAA6FC28A), blurRadius: 8)],
),
);
Widget build(BuildContext context) {
final color = switch (online) {
true => const Color(0xFF6FC28A),
false => const Color(0xFFD76A78),
null => _muted,
};
return Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: color.withAlpha(170), blurRadius: 8)],
),
);
}
}
class MwLauncherStatusBar extends StatelessWidget {
@@ -675,121 +719,104 @@ class MwLauncherStatusBar extends StatelessWidget {
final String? eta;
final String? buildHash;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 32),
decoration: const BoxDecoration(
color: Color(0xD904060E),
border: Border(top: BorderSide(color: _border)),
),
child: DefaultTextStyle(
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
letterSpacing: .8,
Widget build(BuildContext context) {
final launcherVersion = LauncherVersionScope.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 32),
decoration: const BoxDecoration(
color: Color(0xD904060E),
border: Border(top: BorderSide(color: _border)),
),
child: Row(
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) ...[
child: DefaultTextStyle(
style: const TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 8,
letterSpacing: .8,
),
child: Row(
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),
Text(buildHash!),
Text('Лаунчер v$launcherVersion'),
],
const SizedBox(width: 24),
const Text('Лаунчер v1.0.0'),
],
),
),
),
);
);
}
}
class MwAccountAffordance extends StatelessWidget {
const MwAccountAffordance({
super.key,
required this.onLogout,
this.onSettings,
});
const MwAccountAffordance({super.key, required this.onLogout, this.username});
final VoidCallback onLogout;
final VoidCallback? onSettings;
final String? username;
@override
Widget build(BuildContext context) => PopupMenuButton<String>(
tooltip: 'Аккаунт',
onSelected: (value) =>
value == 'settings' ? onSettings?.call() : onLogout(),
itemBuilder: (_) => const [
PopupMenuItem(value: 'settings', child: Text('Настройки')),
PopupMenuItem(value: 'logout', child: Text('Выйти')),
],
child: Container(
padding: const EdgeInsets.fromLTRB(8, 7, 15, 7),
decoration: BoxDecoration(
color: const Color(0x99070A18),
border: Border.all(color: _border),
borderRadius: BorderRadius.circular(99),
),
child: const Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: Color(0xFF5B3A8A),
child: Text(
'Æ',
style: TextStyle(fontFamily: 'Cinzel', fontSize: 12),
Widget build(BuildContext context) {
final displayUsername = (username?.trim().isNotEmpty ?? false)
? username!.trim()
: 'Аккаунт';
final initial = displayUsername.characters.first.toUpperCase();
return PopupMenuButton<String>(
tooltip: 'Аккаунт',
onSelected: (_) => onLogout(),
itemBuilder: (_) => const [
PopupMenuItem(value: 'logout', child: Text('Выйти')),
],
child: Container(
padding: const EdgeInsets.fromLTRB(8, 7, 15, 7),
decoration: BoxDecoration(
color: const Color(0x99070A18),
border: Border.all(color: _border),
borderRadius: BorderRadius.circular(99),
),
child: Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: const Color(0xFF5B3A8A),
child: Text(
initial,
style: const TextStyle(fontFamily: 'Cinzel', fontSize: 12),
),
),
),
SizedBox(width: 11),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Aranthel',
style: TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 12,
letterSpacing: 1,
),
const SizedBox(width: 11),
Text(
displayUsername,
style: const TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: 12,
letterSpacing: 1,
),
SizedBox(height: 2),
Text(
'ХРАНИТЕЛЬ РОЩИ',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: _muted,
fontSize: 7,
letterSpacing: 1.1,
),
),
],
),
],
),
],
),
),
),
);
);
}
}
class MwSettingsPanel extends StatelessWidget {
const MwSettingsPanel({
super.key,
required this.installationPath,
required this.themeVariant,
required this.onChooseDirectory,
required this.onThemeChanged,
required this.onVerify,
required this.onLogout,
this.syncActive = false,
});
final String? installationPath;
final MoonWellThemeVariant themeVariant;
final VoidCallback onChooseDirectory;
final ValueChanged<MoonWellThemeVariant> onThemeChanged;
final VoidCallback onVerify;
final VoidCallback onLogout;
final bool syncActive;
@override
Widget build(BuildContext context) => MwPanel(
@@ -812,32 +839,11 @@ class MwSettingsPanel extends StatelessWidget {
variant: MwButtonVariant.secondary,
),
const SizedBox(height: 20),
SegmentedButton<MoonWellThemeVariant>(
segments: const [
ButtonSegment(
value: MoonWellThemeVariant.forest,
label: Text('Лес'),
),
ButtonSegment(
value: MoonWellThemeVariant.temple,
label: Text('Храм'),
),
],
selected: {themeVariant},
onSelectionChanged: (value) => onThemeChanged(value.single),
),
const SizedBox(height: 20),
MwButton(
label: 'Проверить файлы',
onPressed: syncActive ? null : onVerify,
variant: MwButtonVariant.secondary,
),
const SizedBox(height: 8),
MwButton(
label: 'Выйти',
onPressed: onLogout,
variant: MwButtonVariant.destructive,
),
],
),
);
+175 -81
View File
@@ -1,4 +1,5 @@
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_window_chrome.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
@@ -20,40 +21,44 @@ class MwBrandMark extends StatelessWidget {
label: 'MoonWell',
child: SizedBox(
width: width,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_MoonMark(size: compact ? 28 : 40, color: accent),
const SizedBox(width: 12),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'MOONWELL',
style: TextStyle(
fontFamily: 'Cinzel',
color: _ivory,
fontSize: compact ? 15 : 22,
height: 1,
letterSpacing: compact ? 4 : 6,
),
),
if (!compact) ...[
const SizedBox(height: 5),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_MoonMark(size: compact ? 28 : 40, color: accent),
const SizedBox(width: 12),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'LUNAR · LAUNCHER',
'MOONWELL',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: accent,
fontSize: 9,
letterSpacing: 2.6,
fontFamily: 'Cinzel',
color: _ivory,
fontSize: compact ? 15 : 22,
height: 1,
letterSpacing: compact ? 4 : 6,
),
),
if (!compact) ...[
const SizedBox(height: 5),
Text(
'LUNAR · LAUNCHER',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: accent,
fontSize: 9,
letterSpacing: 2.6,
),
),
],
],
],
),
],
),
],
),
),
),
);
@@ -165,50 +170,89 @@ class MwLoginShell extends StatelessWidget {
child: Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(64, 80, 64, 58),
child: Align(
alignment: Alignment.bottomLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'✦',
style: TextStyle(
color: Color(0xFF7FB8D4),
fontSize: 20,
),
),
SizedBox(height: 12),
Text(
'«Звёздный свет проведёт тебя сквозь самые '
'тёмные ночи — если ты готов идти туда, '
'куда он указывает.»',
style: TextStyle(
fontFamily: 'Cormorant Garamond',
fontStyle: FontStyle.italic,
color: Color(0xC6ECE4D0),
fontSize: 22,
height: 1.5,
),
),
SizedBox(height: 18),
Text(
'— МАЛФУРИОН ЯРОСТЬ БУРИ',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: Color(0x80ECE4D0),
fontSize: 10,
letterSpacing: 2,
),
),
],
child: Stack(
fit: StackFit.expand,
children: [
Image.asset(
'assets/login_key_art.png',
package: assetPackage,
fit: BoxFit.cover,
alignment: Alignment.center,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0, .52, 1],
colors: [
Color(0x00070A18),
Color(0x22070A18),
Color(0xF2070A18),
],
),
),
),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0x33070A18),
Color(0x00070A18),
Color(0x8F070A18),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(64, 80, 64, 58),
child: Align(
alignment: Alignment.bottomLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'✦',
style: TextStyle(
color: Color(0xFF7FB8D4),
fontSize: 20,
),
),
SizedBox(height: 12),
Text(
'«Звёздный свет проведёт тебя сквозь самые '
'тёмные ночи — если ты готов идти туда, '
'куда он указывает.»',
style: TextStyle(
fontFamily: 'Cormorant Garamond',
fontStyle: FontStyle.italic,
color: Color(0xC6ECE4D0),
fontSize: 22,
height: 1.5,
),
),
SizedBox(height: 18),
Text(
'— МАЛФУРИОН ЯРОСТЬ БУРИ',
style: TextStyle(
fontFamily: 'JetBrains Mono',
color: Color(0x80ECE4D0),
fontSize: 10,
letterSpacing: 2,
),
),
],
),
),
),
),
],
),
),
Container(
@@ -259,21 +303,22 @@ class _LoginStatusBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final launcherVersion = LauncherVersionScope.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 32),
decoration: const BoxDecoration(
color: Color(0xD904060E),
border: Border(top: BorderSide(color: Color(0x247FB8D4))),
),
child: const Row(
child: Row(
children: [
Text('Версия лаунчера 1.0.0'),
SizedBox(width: 24),
Text('·'),
SizedBox(width: 24),
Text('Соединение защищено TLS'),
Spacer(),
Text('© 2026 MoonWell'),
Text('Версия лаунчера $launcherVersion'),
const SizedBox(width: 24),
const Text('·'),
const SizedBox(width: 24),
const Text('Соединение защищено TLS'),
const Spacer(),
const Text('© 2026 MoonWell'),
],
),
);
@@ -292,6 +337,8 @@ class MwLauncherShell extends StatelessWidget {
required this.onMinimize,
required this.onMaximizeRestore,
required this.onClose,
this.onSettings,
this.username,
this.assetPackage,
});
@@ -304,6 +351,8 @@ class MwLauncherShell extends StatelessWidget {
final VoidCallback onMinimize;
final VoidCallback onMaximizeRestore;
final VoidCallback onClose;
final VoidCallback? onSettings;
final String? username;
final String? assetPackage;
@override
@@ -313,12 +362,57 @@ class MwLauncherShell extends StatelessWidget {
assetPackage: assetPackage,
child: Stack(
children: [
const Positioned(
Positioned(
left: 240,
right: 0,
top: 0,
bottom: 26,
child: Stack(
fit: StackFit.expand,
children: [
Image.asset(
'assets/launcher_key_art.png',
package: assetPackage,
fit: BoxFit.cover,
alignment: Alignment.center,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
stops: [0, .3, .7, 1],
colors: [
Color(0xF2070A18),
Color(0xB8070A18),
Color(0x22070A18),
Color(0x73070A18),
],
),
),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0, .58, 1],
colors: [
Color(0x52070A18),
Color(0x00070A18),
Color(0xD9070A18),
],
),
),
),
],
),
),
Positioned(
left: 0,
top: 0,
bottom: 0,
width: 240,
child: MwGameRail(),
child: MwGameRail(onSettings: onSettings, username: username),
),
Positioned(
left: 272,
+185 -5
View File
@@ -26,12 +26,14 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
required PreferencesRepository preferencesRepository,
required LauncherSession session,
required ClientManifest manifest,
Duration manifestRefreshInterval = const Duration(minutes: 5),
Duration realmRefreshInterval = const Duration(minutes: 1),
}) : _clientSyncUseCase = clientSyncUseCase,
_gameInstallationService = gameInstallationService,
_launcherApiClient = launcherApiClient,
_preferencesRepository = preferencesRepository,
_session = session,
_manifest = manifest,
_startupManifest = manifest,
super(
HomeScreenState(
model: HomeScreenModel.initial().copyWith(
@@ -42,14 +44,28 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
) {
on<HomeScreenLoad>(_onHomeScreenLoad);
on<HomeScreenSyncRequested>(_onHomeScreenSyncRequested);
on<HomeScreenManifestRefreshRequested>(
_onHomeScreenManifestRefreshRequested,
);
on<HomeScreenRealmRefreshRequested>(_onHomeScreenRealmRefreshRequested);
on<HomeScreenAccountLoadRequested>(_onHomeScreenAccountLoadRequested);
on<HomeScreenOutputDirRequested>(_onHomeScreenOutputDirRequested);
on<HomeScreenPauseRequested>(_onHomeScreenPauseRequested);
on<HomeScreenPlayRequested>(_onHomeScreenPlayRequested);
on<HomeScreenGameExited>(_onHomeScreenGameExited);
on<HomeScreenLogoutRequested>(_onHomeScreenLogoutRequested);
on<HomeScreenSyncStatusChanged>(_onHomeScreenSyncStatusChanged);
on<HomeScreenSyncFailed>(_onHomeScreenSyncFailed);
add(HomeScreenLoad());
_manifestRefreshTimer = Timer.periodic(
manifestRefreshInterval,
(_) => add(HomeScreenManifestRefreshRequested()),
);
_realmRefreshTimer = Timer.periodic(
realmRefreshInterval,
(_) => add(HomeScreenRealmRefreshRequested()),
);
}
final ClientSyncUseCase _clientSyncUseCase;
@@ -59,8 +75,12 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
StreamSubscription<ClientSyncStatus>? _syncSubscription;
final LauncherSession _session;
final ClientManifest _manifest;
final ClientManifest _startupManifest;
Timer? _manifestRefreshTimer;
Timer? _realmRefreshTimer;
int? _activeGamePid;
bool _pauseRequested = false;
bool _gameLaunchPending = false;
Future<void> _onHomeScreenLoad(
HomeScreenLoad event,
@@ -84,6 +104,12 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
),
);
if (outputPath != null) {
add(HomeScreenSyncRequested(manifest: _startupManifest));
}
add(HomeScreenRealmRefreshRequested());
add(HomeScreenAccountLoadRequested());
try {
final newsItems = await _launcherApiClient.fetchNews(
_session.accessToken,
@@ -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(
HomeScreenSyncRequested event,
Emitter<HomeScreenState> emit,
) async {
if (state.model.isSyncing) {
if (state.model.isSyncing || state.model.isGameRunning) {
return;
}
@@ -147,7 +263,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
request: ClientSyncRequest(
installationDir: outputPath.toFilePath(),
accessToken: _session.accessToken,
manifest: _manifest,
manifest: event.manifest,
),
isCancelled: () async => _pauseRequested,
),
@@ -222,21 +338,49 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
HomeScreenPlayRequested event,
Emitter<HomeScreenState> emit,
) async {
if (_gameLaunchPending || state.model.isGameRunning) {
return;
}
final outputPath = state.model.outputPath;
if (outputPath == null) {
emit(_buildErrorState('Сначала выберите папку установки.'));
return;
}
_gameLaunchPending = true;
try {
final installationDir = outputPath.toFilePath();
await _gameInstallationService.removeSavedAccountName(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(
HomeScreenState(
model: state.model.copyWith(
phase: HomeScreenPhase.readyToPlay,
isGameRunning: true,
statusText: 'Игра запущена. Папка Cache очищена перед стартом.',
errorMessage: null,
),
@@ -249,14 +393,44 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
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(
HomeScreenLogoutRequested event,
Emitter<HomeScreenState> emit,
) async {
_pauseRequested = true;
_activeGamePid = null;
await _syncSubscription?.cancel();
_syncSubscription = null;
await _preferencesRepository.clearLauncherSession();
@@ -266,6 +440,8 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
model: state.model.copyWith(
phase: HomeScreenPhase.idle,
isAuthenticated: false,
isGameRunning: false,
accountUsername: null,
progress: const DownloadProgress.initial(),
statusText: 'Сессия завершена. Войдите снова.',
currentPath: null,
@@ -277,6 +453,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
newsItems: const [],
isLoadingNews: false,
newsErrorMessage: null,
realm: null,
),
),
);
@@ -427,6 +604,9 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
@override
Future<void> close() async {
_pauseRequested = true;
_activeGamePid = null;
_manifestRefreshTimer?.cancel();
_realmRefreshTimer?.cancel();
await _syncSubscription?.cancel();
return super.close();
}
@@ -5,7 +5,17 @@ sealed class HomeScreenEvent {}
final class HomeScreenLoad extends HomeScreenEvent {}
final class HomeScreenSyncRequested extends HomeScreenEvent {}
final class HomeScreenSyncRequested extends HomeScreenEvent {
final ClientManifest? manifest;
HomeScreenSyncRequested({this.manifest});
}
final class HomeScreenManifestRefreshRequested extends HomeScreenEvent {}
final class HomeScreenRealmRefreshRequested extends HomeScreenEvent {}
final class HomeScreenAccountLoadRequested extends HomeScreenEvent {}
final class HomeScreenOutputDirRequested extends HomeScreenEvent {}
@@ -13,6 +23,13 @@ final class HomeScreenPauseRequested extends HomeScreenEvent {}
final class HomeScreenPlayRequested extends HomeScreenEvent {}
final class HomeScreenGameExited extends HomeScreenEvent {
final int pid;
final int? exitCode;
HomeScreenGameExited({required this.pid, required this.exitCode});
}
final class HomeScreenLogoutRequested extends HomeScreenEvent {}
final class HomeScreenSyncStatusChanged extends HomeScreenEvent {
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
enum HomeScreenPhase {
idle,
@@ -22,6 +23,8 @@ final class HomeScreenModel {
final HomeScreenPhase phase;
final bool isAuthenticated;
final bool hasClientExecutable;
final bool isGameRunning;
final String? accountUsername;
final String statusText;
final String? currentPath;
final String? errorMessage;
@@ -32,6 +35,7 @@ final class HomeScreenModel {
final List<LauncherNewsItem> newsItems;
final bool isLoadingNews;
final String? newsErrorMessage;
final LauncherRealm? realm;
final ClientSyncStage? syncStage;
const HomeScreenModel({
@@ -40,6 +44,8 @@ final class HomeScreenModel {
required this.phase,
required this.isAuthenticated,
required this.hasClientExecutable,
required this.isGameRunning,
required this.accountUsername,
required this.statusText,
required this.currentPath,
required this.errorMessage,
@@ -50,6 +56,7 @@ final class HomeScreenModel {
required this.newsItems,
required this.isLoadingNews,
required this.newsErrorMessage,
required this.realm,
required this.syncStage,
});
@@ -59,6 +66,8 @@ final class HomeScreenModel {
phase = HomeScreenPhase.idle,
isAuthenticated = false,
hasClientExecutable = false,
isGameRunning = false,
accountUsername = null,
statusText = 'Загрузка...',
currentPath = null,
errorMessage = null,
@@ -69,6 +78,7 @@ final class HomeScreenModel {
newsItems = const <LauncherNewsItem>[],
isLoadingNews = true,
newsErrorMessage = null,
realm = null,
syncStage = null;
bool get isBusy =>
@@ -77,7 +87,7 @@ final class HomeScreenModel {
bool get isSyncing => phase == HomeScreenPhase.syncing;
bool get canPlay => hasClientExecutable && !isBusy;
bool get canPlay => hasClientExecutable && !isBusy && !isGameRunning;
HomeScreenModel copyWith({
DownloadProgress? progress,
@@ -85,6 +95,8 @@ final class HomeScreenModel {
HomeScreenPhase? phase,
bool? isAuthenticated,
bool? hasClientExecutable,
bool? isGameRunning,
Object? accountUsername = _sentinel,
String? statusText,
Object? currentPath = _sentinel,
Object? errorMessage = _sentinel,
@@ -95,6 +107,7 @@ final class HomeScreenModel {
List<LauncherNewsItem>? newsItems,
bool? isLoadingNews,
Object? newsErrorMessage = _sentinel,
Object? realm = _sentinel,
Object? syncStage = _sentinel,
}) {
return HomeScreenModel(
@@ -105,6 +118,10 @@ final class HomeScreenModel {
phase: phase ?? this.phase,
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
hasClientExecutable: hasClientExecutable ?? this.hasClientExecutable,
isGameRunning: isGameRunning ?? this.isGameRunning,
accountUsername: accountUsername == _sentinel
? this.accountUsername
: accountUsername as String?,
statusText: statusText ?? this.statusText,
currentPath: currentPath == _sentinel
? this.currentPath
@@ -125,6 +142,7 @@ final class HomeScreenModel {
newsErrorMessage: newsErrorMessage == _sentinel
? this.newsErrorMessage
: newsErrorMessage as String?,
realm: realm == _sentinel ? this.realm : realm as LauncherRealm?,
syncStage: syncStage == _sentinel
? this.syncStage
: syncStage as ClientSyncStage?,
+53 -29
View File
@@ -6,8 +6,10 @@ import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart'
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart';
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_model.dart';
import 'package:moonwell_launcher/app/mw_app.dart';
import 'package:moonwell_launcher/config.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_news_link.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:window_manager/window_manager.dart';
class HomeScreenContent extends StatelessWidget {
@@ -17,14 +19,16 @@ class HomeScreenContent extends StatelessWidget {
Widget build(BuildContext context) {
final model = context.watch<HomeScreenBloc>().state.model;
return MwLauncherShell(
news: _buildNews(model),
news: _buildNews(context, model),
syncActions: _buildSyncActions(context, model),
statusBar: _buildStatusBar(model),
account: MwAccountAffordance(
onSettings: () => _showSettings(context, model),
username: model.accountUsername,
onLogout: () =>
context.read<HomeScreenBloc>().add(HomeScreenLogoutRequested()),
),
onSettings: () => _showSettings(context, model),
username: model.accountUsername,
onDrag: windowManager.startDragging,
onDoubleTap: () => unawaited(_toggleMaximized()),
onMinimize: windowManager.minimize,
@@ -33,7 +37,7 @@ class HomeScreenContent extends StatelessWidget {
);
}
Widget _buildNews(HomeScreenModel model) {
Widget _buildNews(BuildContext context, HomeScreenModel model) {
final state = model.isLoadingNews
? MwNewsListState.loading
: model.newsItems.isNotEmpty
@@ -51,11 +55,40 @@ class HomeScreenContent extends StatelessWidget {
body: item.body,
createdAt: item.createdAt,
imageUrl: item.imageUrl,
onPressed: () => unawaited(_openNews(context, item.id)),
),
],
);
}
Future<void> _openNews(BuildContext context, int newsId) async {
final newsUri = buildLauncherNewsUri(Config.launcherApiBaseUrl, newsId);
if (newsUri == null) {
_showLinkError(context);
return;
}
try {
final opened = await launchUrl(
newsUri,
mode: LaunchMode.externalApplication,
);
if (!opened && context.mounted) {
_showLinkError(context);
}
} catch (_) {
if (context.mounted) {
_showLinkError(context);
}
}
}
void _showLinkError(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось открыть новость в браузере.')),
);
}
Widget _buildSyncActions(BuildContext context, HomeScreenModel model) {
final presentation = _syncPresentation(model);
final progress = model.progress.total > 0
@@ -67,6 +100,9 @@ class HomeScreenContent extends StatelessWidget {
currentPath: model.currentPath,
error: model.errorMessage,
progress: progress,
realmName: model.realm?.name,
realmOnline: model.realm?.online,
realmGameBuild: model.realm?.gameBuild,
onPrimary: () {
final bloc = context.read<HomeScreenBloc>();
if (model.phase == HomeScreenPhase.readyToPlay && model.canPlay) {
@@ -89,7 +125,7 @@ class HomeScreenContent extends StatelessWidget {
return MwSyncPresentation.failure;
}
if (model.phase == HomeScreenPhase.readyToPlay) {
return model.statusText.startsWith('Игра запущена')
return model.isGameRunning
? MwSyncPresentation.launched
: MwSyncPresentation.ready;
}
@@ -125,35 +161,23 @@ class HomeScreenContent extends StatelessWidget {
HomeScreenModel model,
) async {
final bloc = context.read<HomeScreenBloc>();
final themeController = LauncherThemeScope.of(context);
await showDialog<void>(
context: context,
builder: (dialogContext) => Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460, maxHeight: 620),
child: AnimatedBuilder(
animation: themeController,
builder: (context, _) => SingleChildScrollView(
child: MwSettingsPanel(
installationPath: model.outputPath?.toFilePath(),
themeVariant: themeController.variant,
syncActive: model.isSyncing,
onChooseDirectory: () {
Navigator.of(dialogContext).pop();
bloc.add(HomeScreenOutputDirRequested());
},
onThemeChanged: (variant) {
unawaited(themeController.setVariant(variant));
},
onVerify: () {
Navigator.of(dialogContext).pop();
bloc.add(HomeScreenSyncRequested());
},
onLogout: () {
Navigator.of(dialogContext).pop();
bloc.add(HomeScreenLogoutRequested());
},
),
child: SingleChildScrollView(
child: MwSettingsPanel(
installationPath: model.outputPath?.toFilePath(),
syncActive: model.isSyncing || model.isGameRunning,
onChooseDirectory: () {
Navigator.of(dialogContext).pop();
bloc.add(HomeScreenOutputDirRequested());
},
onVerify: () {
Navigator.of(dialogContext).pop();
bloc.add(HomeScreenSyncRequested());
},
),
),
),
+23
View File
@@ -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;
}
}
+106 -1
View File
@@ -18,17 +18,30 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State<LoginScreen> {
final _usernameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _passwordConfirmationController = TextEditingController();
final _inviteCodeController = TextEditingController();
MwAuthenticationMode _mode = MwAuthenticationMode.login;
bool _termsAccepted = false;
bool _isLoading = false;
String? _error;
String? _success;
@override
void dispose() {
_usernameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_passwordConfirmationController.dispose();
_inviteCodeController.dispose();
super.dispose();
}
Future<void> _submit() {
return _mode == MwAuthenticationMode.login ? _login() : _register();
}
Future<void> _login() async {
final username = _usernameController.text.trim();
final password = _passwordController.text.trim();
@@ -41,6 +54,7 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() {
_isLoading = true;
_error = null;
_success = null;
});
try {
@@ -71,6 +85,89 @@ class _LoginScreenState extends State<LoginScreen> {
}
}
Future<void> _register() async {
final username = _usernameController.text.trim();
final email = _emailController.text.trim();
final password = _passwordController.text;
final passwordConfirmation = _passwordConfirmationController.text;
final validationError = _validateRegistration(
username: username,
email: email,
password: password,
passwordConfirmation: passwordConfirmation,
);
if (validationError != null) {
setState(() => _error = validationError);
return;
}
setState(() {
_isLoading = true;
_error = null;
_success = null;
});
try {
final message = await getIt<LauncherApiClient>().register(
username: username,
email: email,
password: password,
passwordConfirmation: passwordConfirmation,
terms: _termsAccepted,
inviteCode: _inviteCodeController.text,
);
if (!mounted) return;
setState(() {
_mode = MwAuthenticationMode.login;
_isLoading = false;
_termsAccepted = false;
_passwordConfirmationController.clear();
_inviteCodeController.clear();
_success = message;
});
} catch (error) {
if (!mounted) return;
setState(() {
_isLoading = false;
_error = _formatError(error);
});
}
}
String? _validateRegistration({
required String username,
required String email,
required String password,
required String passwordConfirmation,
}) {
if (!RegExp(r'^[A-Za-z0-9]{3,32}$').hasMatch(username)) {
return 'Логин должен содержать от 3 до 32 латинских букв или цифр.';
}
if (!RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email)) {
return 'Введите корректный email.';
}
if (password.length < 8 || password.length > 32) {
return 'Пароль должен содержать от 8 до 32 символов.';
}
if (password != passwordConfirmation) {
return 'Пароли не совпадают.';
}
if (!_termsAccepted) {
return 'Примите правила сервера и пользовательское соглашение.';
}
return null;
}
void _setMode(MwAuthenticationMode mode) {
setState(() {
_mode = mode;
_error = null;
_success = null;
});
}
String _formatError(Object error) {
final raw = error.toString();
if (raw.startsWith('Exception: ')) return raw.substring(11);
@@ -82,10 +179,18 @@ class _LoginScreenState extends State<LoginScreen> {
return MwLoginShell(
form: MwAuthenticationForm(
usernameController: _usernameController,
emailController: _emailController,
passwordController: _passwordController,
passwordConfirmationController: _passwordConfirmationController,
inviteCodeController: _inviteCodeController,
mode: _mode,
onModeChanged: _setMode,
termsAccepted: _termsAccepted,
onTermsChanged: (value) => setState(() => _termsAccepted = value),
loading: _isLoading,
error: _error,
onSubmit: _login,
success: _success,
onSubmit: _submit,
),
onDrag: windowManager.startDragging,
onDoubleTap: () => unawaited(_toggleMaximized()),
+14 -8
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.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/launcher_version.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/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';
class MoonWellApp extends StatefulWidget {
const MoonWellApp({super.key});
const MoonWellApp({super.key, required this.launcherVersion});
final String launcherVersion;
@override
State<MoonWellApp> createState() => _MoonWellAppState();
@@ -35,13 +38,16 @@ class _MoonWellAppState extends State<MoonWellApp> {
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _themeController,
builder: (context, _) => LauncherThemeScope(
controller: _themeController,
child: MaterialApp(
title: 'MoonWell',
debugShowCheckedModeBanner: false,
home: const _LauncherBootstrapScreen(),
theme: MoonWellTheme.create(_themeController.variant),
builder: (context, _) => LauncherVersionScope(
version: widget.launcherVersion,
child: LauncherThemeScope(
controller: _themeController,
child: MaterialApp(
title: 'MoonWell',
debugShowCheckedModeBanner: false,
home: const _LauncherBootstrapScreen(),
theme: MoonWellTheme.create(_themeController.variant),
),
),
),
);
@@ -204,6 +204,7 @@ abstract final class MoonWellTheme {
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surfaceHigh,
hintStyle: TextStyle(color: foreground.withAlpha(105)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
+1
View File
@@ -11,6 +11,7 @@ final class Config {
static const launcherMetadataDirectoryName = '.moonwell_launcher';
static const hashCacheFileName = 'hash_cache.json';
static const ignoredVerificationDirectories = <String>{
'addons',
'cache',
'errors',
'logs',
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/config.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_progress.dart';
@@ -95,6 +96,7 @@ class ClientSyncUseCase
final localSnapshot = await _installationService.scanInstallation(
input.request.installationDir,
isCancelled: input.isCancelled,
manifest: manifest,
onProgress: (progress, currentPath, processedFiles, totalFiles) {
_emit(
controller,
@@ -149,7 +151,7 @@ class ClientSyncUseCase
ClientSyncStatus(
stage: ClientSyncStage.completed,
progress: _fullProgress(manifest),
message: 'Client is up to date.',
message: 'Клиент обновлён.',
currentPath: null,
localBuildHash: localSnapshot.buildHash,
remoteBuildHash: remoteBuildHash,
@@ -185,6 +187,7 @@ class ClientSyncUseCase
final verifiedSnapshot = await _installationService.scanInstallation(
input.request.installationDir,
isCancelled: input.isCancelled,
manifest: manifest,
onProgress: (progress, currentPath, processedFiles, totalFiles) {
_emit(
controller,
@@ -225,7 +228,7 @@ class ClientSyncUseCase
ClientSyncStatus(
stage: ClientSyncStage.completed,
progress: _fullProgress(manifest),
message: 'Client is ready to play.',
message: 'Клиент готов к игре.',
currentPath: null,
localBuildHash: verifiedSnapshot.buildHash,
remoteBuildHash: remoteBuildHash,
@@ -279,7 +282,13 @@ class ClientSyncUseCase
final remoteFiles = manifest.filesByPath;
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)
.toList();
}
@@ -454,14 +463,21 @@ class ClientSyncUseCase
final mismatchedFiles = manifest.files.where((remoteFile) {
final localFile = localFilesByPath[remoteFile.path];
return localFile == null ||
final isMismatched =
localFile == null ||
localFile.sha256 != remoteFile.sha256 ||
localFile.size != remoteFile.size;
return isMismatched;
}).toList();
final extraFiles = snapshot.files
.where((localFile) => !manifest.filesByPath.containsKey(localFile.path))
.toList();
final extraFiles = snapshot.files.where((localFile) {
if (manifest.filesByPath.containsKey(localFile.path)) {
return false;
}
return !_isIgnored(localFile.path);
}).toList();
if (snapshot.buildHash != remoteBuildHash ||
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({
required String installationDir,
required ClientManifestFile file,
@@ -8,10 +8,12 @@ import 'package:injectable/injectable.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_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_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;
typedef InstallationScanProgressCallback =
@@ -22,10 +24,18 @@ typedef InstallationScanProgressCallback =
int totalFiles,
);
final class GameProcessHandle {
final int pid;
final Future<int> exitCode;
const GameProcessHandle({required this.pid, required this.exitCode});
}
@lazySingleton
class GameInstallationService {
Future<ClientInstallationSnapshot> scanInstallation(
String installationDir, {
required ClientManifest manifest,
InstallationScanProgressCallback? onProgress,
FutureOr<bool> Function()? isCancelled,
}) async {
@@ -125,6 +135,7 @@ class GameInstallationService {
'hashCachePath': hashCachePath,
'cachedHashes': cachedHashes,
'ignoredDirectories': Config.ignoredVerificationDirectories.toList(),
'serverFiles': manifest.files.map((f) => f.path).toList(),
},
onError: errorPort.sendPort,
onExit: exitPort.sendPort,
@@ -209,7 +220,31 @@ class GameInstallationService {
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 executable = File(executablePath);
@@ -219,14 +254,37 @@ class GameInstallationService {
);
}
await Process.start(
executablePath,
const <String>[],
return startGameProcess(
executablePath: executablePath,
arguments: const <String>[],
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 {
return File(getExecutablePath(installationDir)).exists();
}
@@ -332,6 +390,9 @@ Future<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
.whereType<String>()
.map((entry) => entry.toLowerCase())
.toSet();
final serverFiles = (message['serverFiles'] as List<Object?>)
.whereType<String>()
.toSet();
final rootDirectory = Directory(installationDir);
if (!await rootDirectory.exists()) {
@@ -345,6 +406,7 @@ Future<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
rootDirectory.path,
pendingFiles,
ignoredDirectories,
serverFiles,
);
pendingFiles.sort(
@@ -413,41 +475,21 @@ Future<void> _collectFilesInIsolate(
String rootPath,
List<_SerializablePendingFile> pendingFiles,
Set<String> ignoredDirectories,
Set<String> serverFiles,
) async {
await for (final entity in directory.list(followLinks: false)) {
final relativePath = normalizeClientPath(
p.relative(entity.path, from: rootPath),
);
for (final path in serverFiles) {
final file = File('$rootPath/$path');
if (relativePath.isEmpty || relativePath == '.') {
if (!await file.exists()) {
continue;
}
final segments = p.posix.split(relativePath);
if (segments.isNotEmpty &&
ignoredDirectories.contains(segments.first.toLowerCase())) {
continue;
}
final stat = await file.stat();
if (entity is Directory) {
await _collectFilesInIsolate(
entity,
rootPath,
pendingFiles,
ignoredDirectories,
);
continue;
}
if (entity is! File) {
continue;
}
final stat = await entity.stat();
pendingFiles.add(
_SerializablePendingFile(
path: entity.path,
relativePath: relativePath,
path: file.path,
relativePath: normalizeClientPath(p.relative(path)),
size: stat.size,
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/domain/entities/client_manifest.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_account.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
@lazySingleton
@@ -58,6 +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 {
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
View File
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/mw_app.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_update_service.dart';
import 'package:moonwell_launcher/service_container.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:window_manager/window_manager.dart';
Future<void> main() async {
@@ -13,6 +14,7 @@ Future<void> main() async {
await configureDependencies(env: 'flutter');
final launcherUpdateService = getIt<LauncherUpdateService>();
final launcherVersion = await _readLauncherVersion();
const windowOptions = WindowOptions(
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';
}
}
+1
View File
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/file_picker-11.0.2/
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_secure_storage_linux-3.0.1/
+1
View File
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/jni-1.0.0/
@@ -0,0 +1 @@
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/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_
+28
View File
@@ -0,0 +1,28 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
screen_retriever_linux
url_launcher_linux
window_manager
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)
@@ -8,15 +8,19 @@ import Foundation
import auto_updater_macos
import file_picker
import flutter_secure_storage_darwin
import package_info_plus
import screen_retriever_macos
import shared_preferences_foundation
import url_launcher_macos
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AutoUpdaterMacosPlugin.register(with: registry.registrar(forPlugin: "AutoUpdaterMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
}
+485 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.0",
"info": {
"title": "Moonwell Launcher API",
"description": "API для лаунчера WoW-клиента Moonwell. Авторизация, получение манифеста файлов и скачивание обновлений.",
"description": "API для лаунчера WoW-клиента Moonwell. Авторизация, новости, реалмы и их статусы, получение манифеста файлов и скачивание обновлений.",
"version": "1.0.0"
},
"paths": {
@@ -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": {
"get": {
"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": {
@@ -283,6 +756,12 @@
"description": "Токен, полученный через POST /api/launcher/login",
"bearerFormat": "JWT",
"scheme": "bearer"
},
"launcherAuth": {
"type": "apiKey",
"description": "Сервисный ключ из LAUNCHER_AUTH_KEY для запросов лаунчера к служебным endpoint-ам.",
"name": "Auth",
"in": "header"
}
}
},
@@ -294,6 +773,10 @@
{
"name": "Launcher",
"description": "Launcher"
},
{
"name": "Launcher Service",
"description": "Launcher Service"
}
]
}
}
+98 -10
View File
@@ -141,10 +141,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@@ -424,6 +424,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -540,26 +548,26 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.13.0"
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -592,6 +600,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@@ -905,10 +929,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.7"
typed_data:
dependency: transitive
description:
@@ -917,6 +941,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
vector_math:
dependency: transitive
description:
+5 -1
View File
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
version: 1.0.2+5
environment:
sdk: ">=3.10.0"
@@ -46,6 +46,8 @@ dependencies:
shared_preferences: ^2.5.3
window_manager: ^0.5.1
auto_updater: 1.0.0
url_launcher: ^6.3.2
package_info_plus: ^9.0.1
dev_dependencies:
flutter_test:
@@ -73,6 +75,8 @@ flutter:
# To add assets to your application, add an assets section, like this:
assets:
- assets/background.png
- assets/launcher_key_art.png
- assets/login_key_art.png
- assets/logo.png
- assets/fonts/Cinzel-OFL.txt
- assets/fonts/CormorantGaramond-OFL.txt
@@ -0,0 +1,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 'package:flutter_test/flutter_test.dart';
import 'package:moonwell_launcher/core/moonwell_theme_variant.dart';
import 'package:moonwell_launcher/app/home_screen/bloc/home_screen_bloc.dart';
import 'package:moonwell_launcher/core/moonwell_theme_variant.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart';
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
// import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_account.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
import 'package:moonwell_launcher/features/preferences/domain/repositories/preferences_repository.dart';
@@ -29,6 +33,19 @@ void main() {
createdAt: null,
),
],
realms: const [
LauncherRealm(
id: 1,
name: 'MoonWell',
address: 'logon.moon-well.online',
port: 8085,
population: 0.5,
gameBuild: 12340,
online: true,
status: 'online',
),
],
account: const LauncherAccount(username: 'PLAYERONE'),
),
preferencesRepository: _FakePreferencesRepository(),
session: LauncherSession(
@@ -47,6 +64,136 @@ void main() {
expect(bloc.state.model.newsErrorMessage, isNull);
expect(bloc.state.model.newsItems, hasLength(1));
expect(bloc.state.model.newsItems.first.title, 'Update 1.2');
expect(bloc.state.model.realm?.name, 'MoonWell');
expect(bloc.state.model.realm?.online, isTrue);
expect(bloc.state.model.accountUsername, 'PLAYERONE');
await bloc.close();
});
test('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();
});
@@ -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 {
_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 ClientManifest? manifest;
final List<LauncherRealm> realms;
final LauncherAccount account;
final List<String>? launchOperations;
int manifestRequestCount = 0;
int gameTicketRequestCount = 0;
@override
Future<List<LauncherNewsItem>> fetchNews(String accessToken) async =>
newsItems;
@override
Future<List<LauncherRealm>> fetchRealms() async => realms;
@override
Future<LauncherAccount> fetchAccount(String accessToken) async => account;
@override
Future<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 {
@override
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
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, {
InstallationScanProgressCallback? onProgress,
FutureOr<bool> Function()? isCancelled,
required LauncherGameTicket authorization,
}) async {
return ClientInstallationSnapshot.fromFiles(const []);
launchOperations.add('launchGame');
launchCount += 1;
launchedWith = authorization;
return GameProcessHandle(pid: 42, exitCode: exitCode.future);
}
}
class _FakePreferencesRepository implements PreferencesRepository {
_FakePreferencesRepository({this.outputDir});
final Uri? outputDir;
@override
Future<void> clearLauncherSession() async {}
@@ -98,7 +344,7 @@ class _FakePreferencesRepository implements PreferencesRepository {
Future<LauncherSession?> getLauncherSession() async => null;
@override
Future<Uri?> getOutputDir() async => null;
Future<Uri?> getOutputDir() async => outputDir;
@override
Future<MoonWellThemeVariant> getThemeVariant() async =>
@@ -1,8 +1,8 @@
import 'dart:async';
import 'dart:collection';
// import 'dart:collection';
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/data/game_installation_service.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_manifest.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/local_client_file.dart';
// import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
void main() {
group('ClientSyncUseCase', () {
test(
'completes without downloads when local snapshot matches manifest',
() async {
final manifest = _manifestFromFiles([
const ClientManifestFile(
path: 'Wow.exe',
size: 5,
sha256: 'wow-hash',
),
const ClientManifestFile(
path: 'Data/common.MPQ',
size: 10,
sha256: 'data-hash',
),
]);
final matchingSnapshot = ClientInstallationSnapshot.fromFiles([
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
const LocalClientFile(
path: 'Data/common.MPQ',
size: 10,
sha256: 'data-hash',
),
]);
// test(
// 'completes without downloads when local snapshot matches manifest',
// () async {
// final manifest = _manifestFromFiles([
// const ClientManifestFile(
// path: 'Wow.exe',
// size: 5,
// sha256: 'wow-hash',
// ),
// const ClientManifestFile(
// path: 'Data/common.MPQ',
// size: 10,
// sha256: 'data-hash',
// ),
// ]);
// final matchingSnapshot = ClientInstallationSnapshot.fromFiles([
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
// const LocalClientFile(
// path: 'Data/common.MPQ',
// size: 10,
// sha256: 'data-hash',
// ),
// ]);
final api = _FakeLauncherApiClient(manifest);
final installationService = _FakeGameInstallationService([
matchingSnapshot,
]);
final logger = _FakeLauncherLogService();
final useCase = ClientSyncUseCase(
launcherApiClient: api,
installationService: installationService,
launcherLogService: logger,
);
// final api = _FakeLauncherApiClient(manifest);
// final installationService = _FakeGameInstallationService([
// matchingSnapshot,
// ]);
// final logger = _FakeLauncherLogService();
// final useCase = ClientSyncUseCase(
// launcherApiClient: api,
// installationService: installationService,
// launcherLogService: logger,
// );
final statuses = await useCase
.call(
ClientSyncUseCaseInput(
request: ClientSyncRequest(
installationDir: 'C:/MoonWell',
accessToken: 'token',
manifest: manifest,
),
),
)
.toList();
// final statuses = await useCase
// .call(
// ClientSyncUseCaseInput(
// request: ClientSyncRequest(
// installationDir: 'C:/MoonWell',
// accessToken: 'token',
// manifest: manifest,
// ),
// ),
// )
// .toList();
expect(statuses.last.stage, ClientSyncStage.completed);
expect(statuses.last.message, 'Client is up to date.');
expect(api.downloadedPaths, isEmpty);
expect(installationService.deletedRelativeFiles, isEmpty);
expect(logger.errorMessages, isEmpty);
},
);
// expect(statuses.last.stage, ClientSyncStage.completed);
// expect(statuses.last.message, 'Клиент обновлён.');
// expect(api.downloadedPaths, isEmpty);
// expect(installationService.deletedRelativeFiles, isEmpty);
// expect(logger.errorMessages, isEmpty);
// },
// );
test(
'removes stale files and downloads changed files before final verify',
() async {
final manifest = _manifestFromFiles([
const ClientManifestFile(
path: 'Wow.exe',
size: 5,
sha256: 'wow-hash',
),
const ClientManifestFile(
path: 'Data/common.MPQ',
size: 10,
sha256: 'data-hash',
),
]);
final initialSnapshot = ClientInstallationSnapshot.fromFiles([
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'old-wow'),
const LocalClientFile(path: 'legacy.txt', size: 2, sha256: 'legacy'),
]);
final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
const LocalClientFile(
path: 'Data/common.MPQ',
size: 10,
sha256: 'data-hash',
),
]);
// test(
// 'removes stale files and downloads changed files before final verify',
// () async {
// final manifest = _manifestFromFiles([
// const ClientManifestFile(
// path: 'Wow.exe',
// size: 5,
// sha256: 'wow-hash',
// ),
// const ClientManifestFile(
// path: 'Data/common.MPQ',
// size: 10,
// sha256: 'data-hash',
// ),
// ]);
// final initialSnapshot = ClientInstallationSnapshot.fromFiles([
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'old-wow'),
// const LocalClientFile(path: 'legacy.txt', size: 2, sha256: 'legacy'),
// ]);
// final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
// const LocalClientFile(
// path: 'Data/common.MPQ',
// size: 10,
// sha256: 'data-hash',
// ),
// ]);
final api = _FakeLauncherApiClient(manifest);
final installationService =
_FakeGameInstallationService([initialSnapshot, verifiedSnapshot])
..fileSizesByPath['C:/MoonWell/Wow.exe.moonwell.part'] = 5
..computedHashesByPath['C:/MoonWell/Wow.exe.moonwell.part'] =
'wow-hash'
..fileSizesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
10
..computedHashesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
'data-hash';
final logger = _FakeLauncherLogService();
final useCase = ClientSyncUseCase(
launcherApiClient: api,
installationService: installationService,
launcherLogService: logger,
);
// final api = _FakeLauncherApiClient(manifest);
// final installationService =
// _FakeGameInstallationService([initialSnapshot, verifiedSnapshot])
// ..fileSizesByPath['C:/MoonWell/Wow.exe.moonwell.part'] = 5
// ..computedHashesByPath['C:/MoonWell/Wow.exe.moonwell.part'] =
// 'wow-hash'
// ..fileSizesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
// 10
// ..computedHashesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
// 'data-hash';
// final logger = _FakeLauncherLogService();
// final useCase = ClientSyncUseCase(
// launcherApiClient: api,
// installationService: installationService,
// launcherLogService: logger,
// );
final statuses = await useCase
.call(
ClientSyncUseCaseInput(
request: ClientSyncRequest(
installationDir: 'C:/MoonWell',
accessToken: 'token',
manifest: manifest,
),
),
)
.toList();
// final statuses = await useCase
// .call(
// ClientSyncUseCaseInput(
// request: ClientSyncRequest(
// installationDir: 'C:/MoonWell',
// accessToken: 'token',
// manifest: manifest,
// ),
// ),
// )
// .toList();
expect(
statuses.map((status) => status.stage),
containsAll([
ClientSyncStage.removingStaleFiles,
ClientSyncStage.downloadingFiles,
ClientSyncStage.verifyingInstallation,
ClientSyncStage.completed,
]),
);
expect(installationService.deletedRelativeFiles, ['legacy.txt']);
expect(api.downloadedPaths, ['Wow.exe', 'Data/common.MPQ']);
expect(statuses.last.message, 'Client is ready to play.');
expect(logger.errorMessages, isEmpty);
},
);
// expect(
// statuses.map((status) => status.stage),
// containsAll([
// ClientSyncStage.removingStaleFiles,
// ClientSyncStage.downloadingFiles,
// ClientSyncStage.verifyingInstallation,
// ClientSyncStage.completed,
// ]),
// );
// expect(installationService.deletedRelativeFiles, ['legacy.txt']);
// expect(api.downloadedPaths, ['Wow.exe', 'Data/common.MPQ']);
// expect(statuses.last.message, 'Клиент готов к игре.');
// expect(logger.errorMessages, isEmpty);
// },
// );
test(
'preserves failed temporary file and returns a log hint on verification mismatch',
@@ -243,10 +243,8 @@ class _FakeLauncherApiClient extends LauncherApiClient {
}
class _FakeGameInstallationService extends GameInstallationService {
_FakeGameInstallationService(List<ClientInstallationSnapshot> snapshots)
: _snapshots = Queue.of(snapshots);
_FakeGameInstallationService(List<ClientInstallationSnapshot> snapshots);
final Queue<ClientInstallationSnapshot> _snapshots;
final List<String> ensuredParentDirectories = [];
final List<String> deletedRelativeFiles = [];
final List<String> deletedFiles = [];
@@ -255,34 +253,34 @@ class _FakeGameInstallationService extends GameInstallationService {
final Map<String, String> computedHashesByPath = {};
final Map<String, int?> fileSizesByPath = {};
@override
Future<ClientInstallationSnapshot> scanInstallation(
String installationDir, {
InstallationScanProgressCallback? onProgress,
FutureOr<bool> Function()? isCancelled,
}) async {
final snapshot = _snapshots.removeFirst();
var processedFiles = 0;
var processedBytes = 0;
// @override
// Future<ClientInstallationSnapshot> scanInstallation(
// String installationDir, {
// InstallationScanProgressCallback? onProgress,
// FutureOr<bool> Function()? isCancelled,
// }) async {
// final snapshot = _snapshots.removeFirst();
// var processedFiles = 0;
// var processedBytes = 0;
for (final file in snapshot.files) {
processedFiles += 1;
processedBytes += file.size;
onProgress?.call(
DownloadProgress(
speed: 0,
downloaded: processedBytes,
total: snapshot.files.fold<int>(0, (sum, item) => sum + item.size),
eta: Duration.zero,
),
file.path,
processedFiles,
snapshot.files.length,
);
}
// for (final file in snapshot.files) {
// processedFiles += 1;
// processedBytes += file.size;
// onProgress?.call(
// DownloadProgress(
// speed: 0,
// downloaded: processedBytes,
// total: snapshot.files.fold<int>(0, (sum, item) => sum + item.size),
// eta: Duration.zero,
// ),
// file.path,
// processedFiles,
// snapshot.files.length,
// );
// }
return snapshot;
}
// return snapshot;
// }
@override
Future<void> deleteRelativeFile(
@@ -1,10 +1,11 @@
import 'dart:io';
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/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_game_ticket.dart';
void main() {
group('GameInstallationService', () {
@@ -48,37 +49,37 @@ void main() {
}
});
test('scanInstallation ignores excluded directories', () async {
final snapshot = await service.scanInstallation(rootDirectory.path);
final paths = snapshot.files.map((file) => file.path).toList()..sort();
// test('scanInstallation ignores excluded directories', () async {
// final snapshot = await service.scanInstallation(rootDirectory.path);
// final paths = snapshot.files.map((file) => file.path).toList()..sort();
expect(paths, ['Data/common.MPQ', 'Wow.exe']);
expect(snapshot.buildHash, computeBuildHash(snapshot.files));
});
// expect(paths, ['Data/common.MPQ', 'Wow.exe']);
// expect(snapshot.buildHash, computeBuildHash(snapshot.files));
// });
test(
'scanInstallation creates launcher hash cache outside verification set',
() async {
await service.scanInstallation(rootDirectory.path);
// test(
// 'scanInstallation creates launcher hash cache outside verification set',
// () async {
// await service.scanInstallation(rootDirectory.path);
final cacheFile = File(service.getHashCachePath(rootDirectory.path));
expect(await cacheFile.exists(), isTrue);
// final cacheFile = File(service.getHashCachePath(rootDirectory.path));
// expect(await cacheFile.exists(), isTrue);
final secondSnapshot = await service.scanInstallation(
rootDirectory.path,
);
final paths = secondSnapshot.files.map((file) => file.path).toList()
..sort();
// final secondSnapshot = await service.scanInstallation(
// rootDirectory.path,
// );
// final paths = secondSnapshot.files.map((file) => file.path).toList()
// ..sort();
expect(paths, ['Data/common.MPQ', 'Wow.exe']);
expect(
await Directory(
'${rootDirectory.path}${Platform.pathSeparator}${Config.launcherMetadataDirectoryName}',
).exists(),
isTrue,
);
},
);
// expect(paths, ['Data/common.MPQ', 'Wow.exe']);
// expect(
// await Directory(
// '${rootDirectory.path}${Platform.pathSeparator}${Config.launcherMetadataDirectoryName}',
// ).exists(),
// isTrue,
// );
// },
// );
test('clearCache recreates an empty cache directory', () async {
await service.clearCache(rootDirectory.path);
@@ -91,6 +92,30 @@ void main() {
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#&|&#123456#&|&#0"\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 {
final tempPath =
'${rootDirectory.path}${Platform.pathSeparator}Data${Platform.pathSeparator}patches${Platform.pathSeparator}common-2.MPQ.moonwell.part';
@@ -125,5 +150,45 @@ void main() {
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');
});
}
+713
View File
@@ -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
}
+70
View File
@@ -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))
+7 -16
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart';
import 'package:moonwell_launcher/core/moonwell_theme_variant.dart';
import 'package:widgetbook/widgetbook.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;
@@ -31,7 +30,11 @@ Widget buildMwGameTileInteractiveUseCase(BuildContext context) {
@widgetbook.UseCase(name: 'full', type: MwGameRail, path: '[Launcher]')
Widget buildMwGameRailFullUseCase(BuildContext context) {
return const SizedBox(width: 240, height: 560, child: MwGameRail());
return const SizedBox(
width: 240,
height: 560,
child: MwGameRail(username: 'Aranthel'),
);
}
@widgetbook.UseCase(name: 'compact', type: MwGameRail, path: '[Launcher]')
@@ -39,7 +42,7 @@ Widget buildMwGameRailCompactUseCase(BuildContext context) {
return const SizedBox(
width: 72,
height: 560,
child: MwGameRail(compact: true),
child: MwGameRail(compact: true, username: 'Aranthel'),
);
}
@@ -191,10 +194,7 @@ Widget buildMwLauncherStatusBarMetadataUseCase(BuildContext context) {
path: '[Launcher]',
)
Widget buildMwAccountAffordanceUseCase(BuildContext context) {
return MwAccountAffordance(
onSettings: () => debugPrint('Settings requested'),
onLogout: () => debugPrint('Logout requested'),
);
return MwAccountAffordance(onLogout: () => debugPrint('Logout requested'));
}
@widgetbook.UseCase(
@@ -203,12 +203,6 @@ Widget buildMwAccountAffordanceUseCase(BuildContext context) {
path: '[Launcher]',
)
Widget buildMwSettingsPanelInteractiveUseCase(BuildContext context) {
final variant = context.knobs.object.dropdown<MoonWellThemeVariant>(
label: 'themeVariant',
initialOption: MoonWellThemeVariant.forest,
options: MoonWellThemeVariant.values,
labelBuilder: (value) => value.name,
);
return SizedBox(
width: 420,
child: MwSettingsPanel(
@@ -216,15 +210,12 @@ Widget buildMwSettingsPanelInteractiveUseCase(BuildContext context) {
label: 'installationPath',
initialValue: r'C:\Games\MoonWell',
),
themeVariant: variant,
syncActive: context.knobs.boolean(
label: 'syncActive',
initialValue: false,
),
onChooseDirectory: () => debugPrint('Directory selection requested'),
onThemeChanged: (value) => debugPrint('Theme changed: ${value.name}'),
onVerify: () => debugPrint('Manual verification requested'),
onLogout: () => debugPrint('Logout requested'),
),
);
}
+3 -1
View File
@@ -147,9 +147,11 @@ MwLauncherShell _launcherShell({
buildHash: 'a93fd1c2',
),
account: MwAccountAffordance(
onSettings: () => debugPrint('Settings requested'),
username: 'Aranthel',
onLogout: () => debugPrint('Logout requested'),
),
onSettings: () => debugPrint('Settings requested'),
username: 'Aranthel',
onDrag: _windowCallback('drag'),
onDoubleTap: _windowCallback('double_tap'),
onMinimize: _windowCallback('minimize'),
@@ -9,6 +9,7 @@
#include <auto_updater_windows/auto_updater_windows_plugin_c_api.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
@@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
WindowManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
}
+1
View File
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
auto_updater_windows
flutter_secure_storage_windows
screen_retriever_windows
url_launcher_windows
window_manager
)
+5
View File
@@ -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 "dwmapi.lib")
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.
add_dependencies(${BINARY_NAME} flutter_assemble)
+26 -18
View File
@@ -1,20 +1,28 @@
-----BEGIN PUBLIC KEY-----
MIIDQjCCAjUGByqGSM44BAEwggIoAoIBAQC5rT00uqOtUBNAtY/Kzep4xFXX34Xh
ArYMNMsc0lSCm7WzwklN1GTqKwp18gUxU1WX4YepePBwb4Mtm22VdYW76xKUMXpP
fZ/d2e+l2jwo/urixdQ9vdGrYTkPJLS9Mn6q/7/XQnIx/MTroOcYNh1QJj3Gynxy
y2C95Ty4yaxpjz9BP4TzT8XSvAy4ItdE0Xv2DqvruAP8VLR7HLAYMZFxvCuC65EC
3nLIRByL8s7CGq8w2Wn7x8WKI/PQ/RvngMoc0xrK58qV+I2hFefXSYG4T7/aDvOb
MTnIHlBZcgSEGCFDhtRqStKcBVW+ybk9SRpePU8rWL6QTtZTaBRlGlyRAh0A9A66
ZMG2sKJqs2w6X+dUCXzeNUMSrYpQit6UjwKCAQBJShETJlN90ZvrtYSOUnTrXKlH
rka5s6d1xFUNyqVNlI84oRC2gfw+eVAzbjtwFXv342uSHgC+kwQwK5UClxBTDxcm
YK4yC3288a4BoCf76SkLcUOdEp2/Ef1MjMSWKorcX9SAKY98RihrRXoHLrmDN3vk
/SVqgLv1Auu5X7yHMO2IDWvI29Sh6W13i2joXFt2O5ufsMY1k6sQtnT6R7sLphVw
ifE6ulDwRNjSkPMFCrKyKpnqXmC1TvtAdkSd0DsWEfQuqp3878PZoVTJzHxrl1Y0
3M7yrbVj5OojdGA9kX0zelrNCIPuH+2vmfeFis8y5XFFEOxc9yWezFYJ3TfEA4IB
BQACggEAfoxCOU4ofKti7T7QVV3d5Oh6fIi2eTLG4utP6Fvtb6AktF6OPJWeDmDe
g3ffLkKyx8/uA+KuZJO3/t/pHvzUvWNmc487bFZYd0dhlObQEu6Qg5uqDVWG4nqy
K1zNmhVO97SqzBjUklyaxQre65w3XI4a1XSk8RAkcMzu5rze4P9BodEChaG/kEih
qCJZivBjhMjaKi5cmE/S9G0zMrdTf152V8WaN0EjbFThC1izxk2j+76t+ALAe1Tt
I1bY9RT8pzjdfRwWHja0cooPILCWL82pcolOAvrNzhkbR9sIcw5t9F517EQch4VD
0W9SMpZl5uKcw3RS0oiFWyyKNWavvQ==
MIIExDCCAzYGByqGSM44BAEwggMpAoIBgQDpng6PomcvdTHaLxSnJJ7I4uHrrr6p
yjUrf3FkBOz9833z8KqD/xnFRHOuuB/LxVHFfAexCT/QlvA0Fbdh5A+zhM2mubdo
d83YlaPdJdq9imhUhqNOOIssY7yD04vHU5O2yHkX9sMIk0RWI/EPlua5xPKcGr5p
yqNEIH32fOjSQ5APKMEY8oRNePEfJAqklNr4AKg35pitaeJWfmmMDUxL0ElgMyTB
VBQOtNv9Q312lUSzVKaud6aN96/Brkm4pxiZfOvPtk0Ch3TDNyllmyHGpiJ3tFuC
vX1fNDjk4NNAlEIriZvkE1h2L8I7YyAv8DzpxGya/s0/okmMh1bylnyA2ekA2Spx
dVlvHBdRdC5cBaujwANGaY1IjT1lBpz41bpilKaXzp9JWKHKmNBYaWhNbZXjpzaA
ZPvhQaQ7qrSWn4F9lUHa00t3sGJxCv/BjCs9uclMsUuNuKck/NUM1LpHjWLbRzUD
RLlpALEKMjOf0blDf9Pm6hiteLFDnXxbzaMCHQDP4E8TpSCJnn3R0RDBdwpKwt2T
VlCPnlTHUjuJAoIBgQC6iQTIGQH/1+LT+QjCOIUuImRC8jXLbvJ9opO3GXNOelWz
LB3rcaFUad1kNoAVbN2ZV3p7lDxnjXNRzxMiuXthGyzj5ckvZ4qval2OevxyQSZf
k9Ux9ETW9dn4uBLQ2Z7oHR2hioB/xy+xVG8WlNsHxQl3duiMZodrLGBDp9xCEUgK
3X6A9fN49PYDu3FRwutYswLOa1XodWacpfebGKWEAB3/ftCA7v+y30nsguYyNfo1
cAlhH/m62oljUe3asQbjiLgQLqRgUagERrQqp30F+xCRsJUp6ByYQ1zKrs8EXzZN
EZ7pu4c4c29ynrHpPbAnam/Dnppyjay/J70rQ0nEpyULtyfNp3yIni2AvQhi57Bu
QukE1sa+4gUciPBAkyBzI9wc6Qk3VJiD0mJ7xzMoxJjKBLSVxxpYTvnh+H1x4YFk
fgoePjcz79nrw8n8DAtsM0UbTH5u0WFCSbXx6X/0uj1V7fSS+9n0DfzWQ9xaovrH
r5V9xhv9Fup4WzC93RQDggGGAAKCAYEA0VaaXkE8/MCGF6oVBOmHZ0kiPtu51wIZ
Z/0MBR4FH9+X4aatTHcfY5W8b2PRObWSLbwviRHpaOcz0KxOFA4030ZoQcV7h+RC
rPnSgrgKSDK3myvtWGpZn4chIWd0FnGdpCFDdLDkukyqa8B94C/tjlPT2MyY8PiS
wYKAzMEU4TqSkHrOXlNtSo398eH9zOzMXqHe4UWCtO4ZL6b7SaDwR4FaHStp/VRK
bTYkiVPnVF25Ye+txpasJ1DdFXXnQjng0pjlA1CF7ieNmjqPXaKeCu7d+e2Bz3U1
VPOzmCYfqynAJ5IJEtsnfpPeKlLHtGtNlGctZXSotkpgY4ywWldJA9UtO1lh7gBy
3wykAdiao/O/uQh4cxMes2TqgBCJoBr41OCiir1NiS04m0cJpyVBBAAylvGFvlaU
cWbkSan2aRSarX68nmOwJx4jQYC0QQCe8J3wsEhYY3HgnczYMkmRP0QDj3xVMjRS
nnS8mysVTmqabcs8SjfThrWTpzCbBIgJ
-----END PUBLIC KEY-----