Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd189fe48f | |||
| 25e7ad156b | |||
| b64dffca9a | |||
| 95efa2bc31 | |||
| e98bcf407c | |||
| f395d2515d | |||
| 3b01afeeb2 | |||
| b7e6a4b393 | |||
| 33659f6c05 | |||
| bd1e464b5a | |||
| 3d43420676 | |||
| c4576c84bb |
@@ -0,0 +1,7 @@
|
|||||||
|
AWS_ACCESS_KEY_ID=replace_with_access_key_id
|
||||||
|
AWS_SECRET_ACCESS_KEY=replace_with_secret_access_key
|
||||||
|
AWS_DEFAULT_REGION=ru-central1
|
||||||
|
AWS_BUCKET=warcraft-client
|
||||||
|
AWS_ENDPOINT=https://storage.yandexcloud.net
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||||
|
LAUNCHER_AUTH_KEY=replace_with_launcher_auth_key
|
||||||
@@ -49,3 +49,8 @@ app.*.map.json
|
|||||||
|
|
||||||
# Local launcher update signing material
|
# Local launcher update signing material
|
||||||
.moonwell_signing/
|
.moonwell_signing/
|
||||||
|
|
||||||
|
# Local production deployment credentials
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|||||||
Vendored
+3
-2
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"dart.flutterSdkPath": ".fvm/versions/stable"
|
"dart.flutterSdkPath": ".fvm/versions/stable",
|
||||||
}
|
"cmake.sourceDirectory": "C:/Users/sindo/moonwell_launcher/windows"
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ MoonWell Launcher is the desktop launcher for MoonWell, a private World of
|
|||||||
Warcraft: Wrath of the Lich King server focused on a player-friendly experience
|
Warcraft: Wrath of the Lich King server focused on a player-friendly experience
|
||||||
without excessive grind and with stronger social play.
|
without excessive grind and with stronger social play.
|
||||||
|
|
||||||
|
The Windows executable requests administrator privileges on startup so it can
|
||||||
|
synchronize game clients installed under protected directories such as
|
||||||
|
`Program Files`.
|
||||||
|
|
||||||
|
Launcher version labels are read from the packaged application metadata. Change
|
||||||
|
only the `version: X.Y.Z+N` value in `pubspec.yaml`; login, launcher status,
|
||||||
|
Windows metadata, installer packaging, and AppCast are synchronized from it.
|
||||||
|
|
||||||
The launcher is responsible for:
|
The launcher is responsible for:
|
||||||
|
|
||||||
- logging in to a player's MoonWell account
|
- logging in to a player's MoonWell account
|
||||||
@@ -11,7 +19,8 @@ The launcher is responsible for:
|
|||||||
- downloading and synchronizing client files
|
- downloading and synchronizing client files
|
||||||
- displaying launcher news
|
- displaying launcher news
|
||||||
- displaying the live status of the first configured realm
|
- displaying the live status of the first configured realm
|
||||||
- launching the game client
|
- requesting a single-use game ticket and launching the game client without
|
||||||
|
putting credentials in command-line arguments
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -108,6 +117,10 @@ Remove `-DryRun` only after reviewing the generated installer and AppCast.
|
|||||||
See `docs/production_deploy.md` for required environment variables, production
|
See `docs/production_deploy.md` for required environment variables, production
|
||||||
defaults, safety checks, and failure recovery.
|
defaults, safety checks, and failure recovery.
|
||||||
|
|
||||||
|
Local deploy credentials are read from the ignored root `.env`; use
|
||||||
|
`.env.example` as the safe template. Process and CI environment variables
|
||||||
|
override values from the file.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
The UI lives in `lib/app`. Presentation-only design-system components live in
|
The UI lives in `lib/app`. Presentation-only design-system components live in
|
||||||
@@ -128,7 +141,8 @@ The launcher sync flow is manifest-based:
|
|||||||
6. remove stale files outside ignored directories
|
6. remove stale files outside ignored directories
|
||||||
7. download missing or changed files to temporary part files
|
7. download missing or changed files to temporary part files
|
||||||
8. verify size and SHA-256 before replacing client files
|
8. verify size and SHA-256 before replacing client files
|
||||||
9. launch `Wow.exe` from the selected client directory
|
9. request a short-lived game ticket and launch `Wow.exe` from the selected
|
||||||
|
client directory with authorization in its child environment
|
||||||
|
|
||||||
See `docs/launcher_web_api_spec.md` for the API and sync contract.
|
See `docs/launcher_web_api_spec.md` for the API and sync contract.
|
||||||
|
|
||||||
|
|||||||
+8
-16
@@ -1,22 +1,14 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<rss version="2.0"
|
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
|
||||||
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
|
|
||||||
<channel>
|
<channel>
|
||||||
<title>MoonWell Launcher</title>
|
<title>MoonWell Launcher</title>
|
||||||
<description>Обновления MoonWell Launcher</description>
|
<description>MoonWell Launcher updates</description>
|
||||||
<language>ru</language>
|
<language>ru</language>
|
||||||
<item>
|
<item>
|
||||||
<title>MoonWell Launcher 1.0.1</title>
|
<title>MoonWell Launcher 1.0.2</title>
|
||||||
<description>Обновление интерфейса, синхронизации клиента и интеграции с сервером MoonWell.</description>
|
<description>Исправлена работа с аддонами.</description>
|
||||||
<pubDate>Tue, 28 Jul 2026 20:33:00 +0400</pubDate>
|
<pubDate>Fri, 07 Aug 2026 12:08:41 +04:00</pubDate>
|
||||||
<enclosure
|
<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" />
|
||||||
url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe"
|
|
||||||
sparkle:version="1.0.1"
|
|
||||||
sparkle:shortVersionString="1.0.1"
|
|
||||||
sparkle:os="windows"
|
|
||||||
sparkle:dsaSignature="MD4CHQCdoCW5jNymVlRblQpqy8C+jw4W0rVFQX0Bk25PAh0Av2ZcFYwZFQBQlAstQXna/PsuMNWIzXF4jpDuSA=="
|
|
||||||
length="19989772"
|
|
||||||
type="application/octet-stream" />
|
|
||||||
</item>
|
</item>
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -9,6 +9,11 @@ WinSparkle shows its native prompt only when a newer version is available.
|
|||||||
Missing configuration, network failures, and malformed feeds do not block the
|
Missing configuration, network failures, and malformed feeds do not block the
|
||||||
launcher. Periodic WinSparkle checks are disabled.
|
launcher. Periodic WinSparkle checks are disabled.
|
||||||
|
|
||||||
|
`MoonWell.exe` uses the Windows `requireAdministrator` execution level. Users
|
||||||
|
therefore receive a UAC prompt whenever the launcher starts. This is required
|
||||||
|
to synchronize game files installed in protected directories. The per-user
|
||||||
|
Inno Setup installer remains configured with `PrivilegesRequired=lowest`.
|
||||||
|
|
||||||
## Build Configuration
|
## Build Configuration
|
||||||
|
|
||||||
Pass the public feed URL at build time:
|
Pass the public feed URL at build time:
|
||||||
@@ -45,8 +50,11 @@ certificate would not establish trust on player machines.
|
|||||||
## Version and AppCast Contract
|
## Version and AppCast Contract
|
||||||
|
|
||||||
Increase the `X.Y.Z` version in `pubspec.yaml` for every launcher release. A
|
Increase the `X.Y.Z` version in `pubspec.yaml` for every launcher release. A
|
||||||
build-number-only change such as `1.0.0+2` is not sufficient because WinSparkle
|
build-number-only change such as `1.0.0+2` is not sufficient for a public
|
||||||
compares the Windows product version. Pass the same `X.Y.Z` value to Inno Setup:
|
release. Flutter writes the complete `X.Y.Z+N` value into the Windows
|
||||||
|
`ProductVersion`; `sparkle:version` must match that complete value exactly.
|
||||||
|
Use `sparkle:shortVersionString` for the human-readable `X.Y.Z` value. Pass
|
||||||
|
`X.Y.Z` without the build suffix to Inno Setup:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
ISCC.exe /DMyAppVersion=1.1.0 installer\moonwell_launcher.iss
|
ISCC.exe /DMyAppVersion=1.1.0 installer\moonwell_launcher.iss
|
||||||
@@ -70,7 +78,8 @@ Use a public AppCast with absolute URLs:
|
|||||||
<pubDate>Sun, 21 Jun 2026 12:00:00 +0400</pubDate>
|
<pubDate>Sun, 21 Jun 2026 12:00:00 +0400</pubDate>
|
||||||
<enclosure
|
<enclosure
|
||||||
url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe"
|
url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe"
|
||||||
sparkle:version="1.1.0"
|
sparkle:version="1.1.0+3"
|
||||||
|
sparkle:shortVersionString="1.1.0"
|
||||||
sparkle:os="windows"
|
sparkle:os="windows"
|
||||||
sparkle:dsaSignature="SIGNATURE_FROM_SIGN_UPDATE"
|
sparkle:dsaSignature="SIGNATURE_FROM_SIGN_UPDATE"
|
||||||
length="ACTUAL_INSTALLER_SIZE_IN_BYTES"
|
length="ACTUAL_INSTALLER_SIZE_IN_BYTES"
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ The launcher uses these endpoints from `openapi.json`:
|
|||||||
5. `GET /api/launcher/news`
|
5. `GET /api/launcher/news`
|
||||||
6. `GET /api/launcher/realms`
|
6. `GET /api/launcher/realms`
|
||||||
7. `GET /api/launcher/account`
|
7. `GET /api/launcher/account`
|
||||||
|
8. `POST /api/launcher/game-ticket`
|
||||||
|
|
||||||
Registration sequence:
|
Registration sequence:
|
||||||
|
|
||||||
@@ -106,6 +107,19 @@ Account sequence:
|
|||||||
3. The response `balance` field is intentionally ignored and never displayed.
|
3. The response `balance` field is intentionally ignored and never displayed.
|
||||||
4. Account metadata failures do not block patching or launching the game.
|
4. Account metadata failures do not block patching or launching the game.
|
||||||
|
|
||||||
|
Game authorization sequence:
|
||||||
|
|
||||||
|
1. Immediately before starting the game, the launcher calls
|
||||||
|
`POST /api/launcher/game-ticket` with bearer authentication and
|
||||||
|
`{ "client_build": 12340 }` in the JSON body.
|
||||||
|
2. The API returns `account`, a 16-character `A-Z0-9` single-use `ticket`, and
|
||||||
|
`expires_at`. The ticket must remain valid for at least five more seconds.
|
||||||
|
3. Issuing a ticket atomically revokes any previous unused game ticket for the
|
||||||
|
same account. Production lifetime must not exceed 60 seconds.
|
||||||
|
4. The launcher never persists or logs the ticket.
|
||||||
|
5. The auth server uses the ticket as the temporary SRP password and consumes
|
||||||
|
it atomically after a successful logon proof.
|
||||||
|
|
||||||
## Installation Directory Rules
|
## Installation Directory Rules
|
||||||
|
|
||||||
The user selects a single installation root directory.
|
The user selects a single installation root directory.
|
||||||
@@ -230,13 +244,18 @@ Pause is implemented as cooperative cancellation:
|
|||||||
|
|
||||||
When the user presses `Play`:
|
When the user presses `Play`:
|
||||||
|
|
||||||
1. launcher resolves `<install-root>/Wow.exe`
|
1. launcher removes every `SET accountName ...` line from
|
||||||
2. launcher clears `<install-root>/Cache`
|
`<install-root>/WTF/Config.wtf` when that file exists
|
||||||
3. launcher starts `Wow.exe` with working directory set to installation root
|
2. launcher resolves `<install-root>/Wow.exe`
|
||||||
and retains a process handle
|
3. launcher clears `<install-root>/Cache`
|
||||||
4. while the process is alive, the launcher disables repeated game launches
|
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
|
and client synchronization
|
||||||
5. when the process exits, the launcher returns to the ready-to-play state
|
7. when the process exits, the launcher returns to the ready-to-play state
|
||||||
|
|
||||||
If `Wow.exe` is missing, launch fails with an error.
|
If `Wow.exe` is missing, launch fails with an error.
|
||||||
|
|
||||||
@@ -253,6 +272,7 @@ The launcher surfaces errors for:
|
|||||||
- missing downloaded temp files before verification
|
- missing downloaded temp files before verification
|
||||||
- final verification mismatch after update
|
- final verification mismatch after update
|
||||||
- missing `Wow.exe`
|
- missing `Wow.exe`
|
||||||
|
- invalid, expired, or unavailable game ticket
|
||||||
|
|
||||||
## Tested Invariants
|
## Tested Invariants
|
||||||
|
|
||||||
|
|||||||
@@ -37,14 +37,29 @@ Install the Python dependency if required:
|
|||||||
python -m pip install boto3
|
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
|
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
|
commit it, upload it to Object Storage, or send it through chat. Losing this key
|
||||||
prevents released launchers from accepting future updates.
|
prevents released launchers from accepting future updates.
|
||||||
|
|
||||||
## Required Environment
|
## Local `.env`
|
||||||
|
|
||||||
The script reads secrets and storage configuration only from environment
|
For local releases, copy `.env.example` to `.env` and fill in the real values.
|
||||||
variables:
|
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 |
|
| Variable | Required | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -56,8 +71,8 @@ variables:
|
|||||||
| `AWS_BUCKET` | optional | Defaults to `warcraft-client` |
|
| `AWS_BUCKET` | optional | Defaults to `warcraft-client` |
|
||||||
| `AWS_USE_PATH_STYLE_ENDPOINT` | optional | Defaults to `true` |
|
| `AWS_USE_PATH_STYLE_ENDPOINT` | optional | Defaults to `true` |
|
||||||
|
|
||||||
Load secrets from the team secret manager into the current PowerShell process.
|
Production CI should load these values from the team secret manager instead of
|
||||||
Do not put real values in a tracked `.env` file or in the script.
|
creating `.env`. Never commit the real file or put its contents into logs.
|
||||||
|
|
||||||
## Prepare a Release
|
## Prepare a Release
|
||||||
|
|
||||||
@@ -67,9 +82,16 @@ Increase both parts of the version in `pubspec.yaml`:
|
|||||||
version: 1.0.2+3
|
version: 1.0.2+3
|
||||||
```
|
```
|
||||||
|
|
||||||
`X.Y.Z` is the WinSparkle release version. `N` is the Flutter build number.
|
`X.Y.Z` is the public launcher version. `N` is the Flutter build number.
|
||||||
Every production release must have an `X.Y.Z` value greater than the version
|
Flutter places the complete `X.Y.Z+N` value in the Windows `ProductVersion`,
|
||||||
currently published in AppCast.
|
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:
|
Run a dry run first:
|
||||||
|
|
||||||
|
|||||||
@@ -44,5 +44,11 @@ Source: "{#MyBuildDir}\*"; DestDir: "{app}"; Excludes: "moonwell_launcher.exe";
|
|||||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||||
|
|
||||||
|
[Registry]
|
||||||
|
Root: HKCU; Subkey: "Software\Classes\moonwell"; ValueType: string; ValueName: ""; ValueData: "URL:MoonWell Launcher Protocol"; Flags: uninsdeletekey
|
||||||
|
Root: HKCU; Subkey: "Software\Classes\moonwell"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""
|
||||||
|
Root: HKCU; Subkey: "Software\Classes\moonwell\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"
|
||||||
|
Root: HKCU; Subkey: "Software\Classes\moonwell\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""
|
||||||
|
|
||||||
[Run]
|
[Run]
|
||||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:moonwell_launcher/app/launcher_version.dart';
|
||||||
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
|
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
|
||||||
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final accent = MoonWellDesignTokens.of(context).accent;
|
final accent = MoonWellDesignTokens.of(context).accent;
|
||||||
final registering = widget.mode == MwAuthenticationMode.registration;
|
final registering = widget.mode == MwAuthenticationMode.registration;
|
||||||
|
final launcherVersion = LauncherVersionScope.of(context);
|
||||||
return AutofillGroup(
|
return AutofillGroup(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -259,10 +261,10 @@ class _MwAuthenticationFormState extends State<MwAuthenticationForm> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
const Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'MOONWELL · V 1.0.0 · PLAY.MOON-WELL.ONLINE',
|
'MOONWELL · V $launcherVersion · PLAY.MOON-WELL.ONLINE',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'JetBrains Mono',
|
fontFamily: 'JetBrains Mono',
|
||||||
color: Color(0x52ECE4D0),
|
color: Color(0x52ECE4D0),
|
||||||
fontSize: 8,
|
fontSize: 8,
|
||||||
@@ -311,9 +313,7 @@ class _Tab extends StatelessWidget {
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(color: selected ? accent : Colors.transparent),
|
||||||
color: selected ? accent : const Color(0x247FB8D4),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:moonwell_launcher/app/launcher_version.dart';
|
||||||
import 'package:moonwell_launcher/app/design_system/mw_primitives.dart';
|
import 'package:moonwell_launcher/app/design_system/mw_primitives.dart';
|
||||||
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
||||||
|
|
||||||
@@ -718,37 +719,40 @@ class MwLauncherStatusBar extends StatelessWidget {
|
|||||||
final String? eta;
|
final String? eta;
|
||||||
final String? buildHash;
|
final String? buildHash;
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Container(
|
Widget build(BuildContext context) {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
final launcherVersion = LauncherVersionScope.of(context);
|
||||||
decoration: const BoxDecoration(
|
return Container(
|
||||||
color: Color(0xD904060E),
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
border: Border(top: BorderSide(color: _border)),
|
decoration: const BoxDecoration(
|
||||||
),
|
color: Color(0xD904060E),
|
||||||
child: DefaultTextStyle(
|
border: Border(top: BorderSide(color: _border)),
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'JetBrains Mono',
|
|
||||||
color: _muted,
|
|
||||||
fontSize: 8,
|
|
||||||
letterSpacing: .8,
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: DefaultTextStyle(
|
||||||
children: [
|
style: const TextStyle(
|
||||||
Expanded(
|
fontFamily: 'JetBrains Mono',
|
||||||
child: Text(status, maxLines: 1, overflow: TextOverflow.ellipsis),
|
color: _muted,
|
||||||
),
|
fontSize: 8,
|
||||||
if (fileCount != null) Text(fileCount!),
|
letterSpacing: .8,
|
||||||
if (speed != null) ...[const SizedBox(width: 24), Text(speed!)],
|
),
|
||||||
if (eta != null) ...[const SizedBox(width: 24), Text(eta!)],
|
child: Row(
|
||||||
if (buildHash != null) ...[
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(status, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
if (fileCount != null) Text(fileCount!),
|
||||||
|
if (speed != null) ...[const SizedBox(width: 24), Text(speed!)],
|
||||||
|
if (eta != null) ...[const SizedBox(width: 24), Text(eta!)],
|
||||||
|
if (buildHash != null) ...[
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
Text(buildHash!),
|
||||||
|
],
|
||||||
const SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
Text(buildHash!),
|
Text('Лаунчер v$launcherVersion'),
|
||||||
],
|
],
|
||||||
const SizedBox(width: 24),
|
),
|
||||||
const Text('Лаунчер v1.0.0'),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MwAccountAffordance extends StatelessWidget {
|
class MwAccountAffordance extends StatelessWidget {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:moonwell_launcher/app/launcher_version.dart';
|
||||||
import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart';
|
import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart';
|
||||||
import 'package:moonwell_launcher/app/design_system/mw_window_chrome.dart';
|
import 'package:moonwell_launcher/app/design_system/mw_window_chrome.dart';
|
||||||
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
||||||
@@ -302,21 +303,22 @@ class _LoginStatusBar extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final launcherVersion = LauncherVersionScope.of(context);
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: Color(0xD904060E),
|
color: Color(0xD904060E),
|
||||||
border: Border(top: BorderSide(color: Color(0x247FB8D4))),
|
border: Border(top: BorderSide(color: Color(0x247FB8D4))),
|
||||||
),
|
),
|
||||||
child: const Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text('Версия лаунчера 1.0.0'),
|
Text('Версия лаунчера $launcherVersion'),
|
||||||
SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
Text('·'),
|
const Text('·'),
|
||||||
SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
Text('Соединение защищено TLS'),
|
const Text('Соединение защищено TLS'),
|
||||||
Spacer(),
|
const Spacer(),
|
||||||
Text('© 2026 MoonWell'),
|
const Text('© 2026 MoonWell'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
Timer? _realmRefreshTimer;
|
Timer? _realmRefreshTimer;
|
||||||
int? _activeGamePid;
|
int? _activeGamePid;
|
||||||
bool _pauseRequested = false;
|
bool _pauseRequested = false;
|
||||||
|
bool _gameLaunchPending = false;
|
||||||
|
|
||||||
Future<void> _onHomeScreenLoad(
|
Future<void> _onHomeScreenLoad(
|
||||||
HomeScreenLoad event,
|
HomeScreenLoad event,
|
||||||
@@ -337,17 +338,27 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
HomeScreenPlayRequested event,
|
HomeScreenPlayRequested event,
|
||||||
Emitter<HomeScreenState> emit,
|
Emitter<HomeScreenState> emit,
|
||||||
) async {
|
) async {
|
||||||
|
if (_gameLaunchPending || state.model.isGameRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final outputPath = state.model.outputPath;
|
final outputPath = state.model.outputPath;
|
||||||
if (outputPath == null) {
|
if (outputPath == null) {
|
||||||
emit(_buildErrorState('Сначала выберите папку установки.'));
|
emit(_buildErrorState('Сначала выберите папку установки.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_gameLaunchPending = true;
|
||||||
try {
|
try {
|
||||||
final installationDir = outputPath.toFilePath();
|
final installationDir = outputPath.toFilePath();
|
||||||
|
await _gameInstallationService.removeSavedAccountName(installationDir);
|
||||||
await _gameInstallationService.clearCache(installationDir);
|
await _gameInstallationService.clearCache(installationDir);
|
||||||
|
final authorization = await _launcherApiClient.issueGameTicket(
|
||||||
|
_session.accessToken,
|
||||||
|
);
|
||||||
final process = await _gameInstallationService.launchGame(
|
final process = await _gameInstallationService.launchGame(
|
||||||
installationDir,
|
installationDir,
|
||||||
|
authorization: authorization,
|
||||||
);
|
);
|
||||||
_activeGamePid = process.pid;
|
_activeGamePid = process.pid;
|
||||||
unawaited(
|
unawaited(
|
||||||
@@ -382,6 +393,8 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
errorMessage: _formatError(error),
|
errorMessage: _formatError(error),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
_gameLaunchPending = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-8
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
|
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
|
||||||
import 'package:moonwell_launcher/app/home_screen/home_screen.dart';
|
import 'package:moonwell_launcher/app/home_screen/home_screen.dart';
|
||||||
|
import 'package:moonwell_launcher/app/launcher_version.dart';
|
||||||
import 'package:moonwell_launcher/app/login_screen/login_screen.dart';
|
import 'package:moonwell_launcher/app/login_screen/login_screen.dart';
|
||||||
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/application/restore_launcher_session_use_case.dart';
|
import 'package:moonwell_launcher/features/launcher/application/restore_launcher_session_use_case.dart';
|
||||||
@@ -9,7 +10,9 @@ import 'package:moonwell_launcher/features/preferences/domain/repositories/prefe
|
|||||||
import 'package:moonwell_launcher/service_container.dart';
|
import 'package:moonwell_launcher/service_container.dart';
|
||||||
|
|
||||||
class MoonWellApp extends StatefulWidget {
|
class MoonWellApp extends StatefulWidget {
|
||||||
const MoonWellApp({super.key});
|
const MoonWellApp({super.key, required this.launcherVersion});
|
||||||
|
|
||||||
|
final String launcherVersion;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MoonWellApp> createState() => _MoonWellAppState();
|
State<MoonWellApp> createState() => _MoonWellAppState();
|
||||||
@@ -35,13 +38,16 @@ class _MoonWellAppState extends State<MoonWellApp> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _themeController,
|
animation: _themeController,
|
||||||
builder: (context, _) => LauncherThemeScope(
|
builder: (context, _) => LauncherVersionScope(
|
||||||
controller: _themeController,
|
version: widget.launcherVersion,
|
||||||
child: MaterialApp(
|
child: LauncherThemeScope(
|
||||||
title: 'MoonWell',
|
controller: _themeController,
|
||||||
debugShowCheckedModeBanner: false,
|
child: MaterialApp(
|
||||||
home: const _LauncherBootstrapScreen(),
|
title: 'MoonWell',
|
||||||
theme: MoonWellTheme.create(_themeController.variant),
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: const _LauncherBootstrapScreen(),
|
||||||
|
theme: MoonWellTheme.create(_themeController.variant),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ final class Config {
|
|||||||
static const launcherMetadataDirectoryName = '.moonwell_launcher';
|
static const launcherMetadataDirectoryName = '.moonwell_launcher';
|
||||||
static const hashCacheFileName = 'hash_cache.json';
|
static const hashCacheFileName = 'hash_cache.json';
|
||||||
static const ignoredVerificationDirectories = <String>{
|
static const ignoredVerificationDirectories = <String>{
|
||||||
|
'addons',
|
||||||
'cache',
|
'cache',
|
||||||
'errors',
|
'errors',
|
||||||
'logs',
|
'logs',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
|
import 'package:moonwell_launcher/config.dart';
|
||||||
import 'package:moonwell_launcher/core/use_case.dart';
|
import 'package:moonwell_launcher/core/use_case.dart';
|
||||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
|
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
|
||||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
|
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
|
||||||
@@ -95,6 +96,7 @@ class ClientSyncUseCase
|
|||||||
final localSnapshot = await _installationService.scanInstallation(
|
final localSnapshot = await _installationService.scanInstallation(
|
||||||
input.request.installationDir,
|
input.request.installationDir,
|
||||||
isCancelled: input.isCancelled,
|
isCancelled: input.isCancelled,
|
||||||
|
manifest: manifest,
|
||||||
onProgress: (progress, currentPath, processedFiles, totalFiles) {
|
onProgress: (progress, currentPath, processedFiles, totalFiles) {
|
||||||
_emit(
|
_emit(
|
||||||
controller,
|
controller,
|
||||||
@@ -185,6 +187,7 @@ class ClientSyncUseCase
|
|||||||
final verifiedSnapshot = await _installationService.scanInstallation(
|
final verifiedSnapshot = await _installationService.scanInstallation(
|
||||||
input.request.installationDir,
|
input.request.installationDir,
|
||||||
isCancelled: input.isCancelled,
|
isCancelled: input.isCancelled,
|
||||||
|
manifest: manifest,
|
||||||
onProgress: (progress, currentPath, processedFiles, totalFiles) {
|
onProgress: (progress, currentPath, processedFiles, totalFiles) {
|
||||||
_emit(
|
_emit(
|
||||||
controller,
|
controller,
|
||||||
@@ -279,7 +282,13 @@ class ClientSyncUseCase
|
|||||||
final remoteFiles = manifest.filesByPath;
|
final remoteFiles = manifest.filesByPath;
|
||||||
|
|
||||||
return snapshot.files
|
return snapshot.files
|
||||||
.where((localFile) => !remoteFiles.containsKey(localFile.path))
|
.where((localFile) {
|
||||||
|
if (remoteFiles.containsKey(localFile.path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !_isIgnored(localFile.path);
|
||||||
|
})
|
||||||
.map((file) => file.path)
|
.map((file) => file.path)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -454,14 +463,21 @@ class ClientSyncUseCase
|
|||||||
|
|
||||||
final mismatchedFiles = manifest.files.where((remoteFile) {
|
final mismatchedFiles = manifest.files.where((remoteFile) {
|
||||||
final localFile = localFilesByPath[remoteFile.path];
|
final localFile = localFilesByPath[remoteFile.path];
|
||||||
return localFile == null ||
|
final isMismatched =
|
||||||
|
localFile == null ||
|
||||||
localFile.sha256 != remoteFile.sha256 ||
|
localFile.sha256 != remoteFile.sha256 ||
|
||||||
localFile.size != remoteFile.size;
|
localFile.size != remoteFile.size;
|
||||||
|
|
||||||
|
return isMismatched;
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
final extraFiles = snapshot.files
|
final extraFiles = snapshot.files.where((localFile) {
|
||||||
.where((localFile) => !manifest.filesByPath.containsKey(localFile.path))
|
if (manifest.filesByPath.containsKey(localFile.path)) {
|
||||||
.toList();
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !_isIgnored(localFile.path);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
if (snapshot.buildHash != remoteBuildHash ||
|
if (snapshot.buildHash != remoteBuildHash ||
|
||||||
mismatchedFiles.isNotEmpty ||
|
mismatchedFiles.isNotEmpty ||
|
||||||
@@ -492,6 +508,15 @@ class ClientSyncUseCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Файлы, которые не будут удалены во время проверки файлов. Например, пользовательские
|
||||||
|
/// аддоны, скриншоты и т.д.
|
||||||
|
bool _isIgnored(String filePath) {
|
||||||
|
final normalizedPath = filePath.replaceAll('\\', '/').toLowerCase();
|
||||||
|
return Config.ignoredVerificationDirectories.any(
|
||||||
|
(pattern) => normalizedPath.contains(pattern.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _verifyDownloadedFile({
|
Future<void> _verifyDownloadedFile({
|
||||||
required String installationDir,
|
required String installationDir,
|
||||||
required ClientManifestFile file,
|
required ClientManifestFile file,
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import 'package:injectable/injectable.dart';
|
|||||||
import 'package:moonwell_launcher/config.dart';
|
import 'package:moonwell_launcher/config.dart';
|
||||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
|
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
|
||||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
|
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
|
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
|
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
typedef InstallationScanProgressCallback =
|
typedef InstallationScanProgressCallback =
|
||||||
@@ -33,6 +35,7 @@ final class GameProcessHandle {
|
|||||||
class GameInstallationService {
|
class GameInstallationService {
|
||||||
Future<ClientInstallationSnapshot> scanInstallation(
|
Future<ClientInstallationSnapshot> scanInstallation(
|
||||||
String installationDir, {
|
String installationDir, {
|
||||||
|
required ClientManifest manifest,
|
||||||
InstallationScanProgressCallback? onProgress,
|
InstallationScanProgressCallback? onProgress,
|
||||||
FutureOr<bool> Function()? isCancelled,
|
FutureOr<bool> Function()? isCancelled,
|
||||||
}) async {
|
}) async {
|
||||||
@@ -132,6 +135,7 @@ class GameInstallationService {
|
|||||||
'hashCachePath': hashCachePath,
|
'hashCachePath': hashCachePath,
|
||||||
'cachedHashes': cachedHashes,
|
'cachedHashes': cachedHashes,
|
||||||
'ignoredDirectories': Config.ignoredVerificationDirectories.toList(),
|
'ignoredDirectories': Config.ignoredVerificationDirectories.toList(),
|
||||||
|
'serverFiles': manifest.files.map((f) => f.path).toList(),
|
||||||
},
|
},
|
||||||
onError: errorPort.sendPort,
|
onError: errorPort.sendPort,
|
||||||
onExit: exitPort.sendPort,
|
onExit: exitPort.sendPort,
|
||||||
@@ -216,7 +220,31 @@ class GameInstallationService {
|
|||||||
await cacheDirectory.create(recursive: true);
|
await cacheDirectory.create(recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<GameProcessHandle> launchGame(String installationDir) async {
|
Future<void> removeSavedAccountName(String installationDir) async {
|
||||||
|
final configFile = File(p.join(installationDir, 'WTF', 'Config.wtf'));
|
||||||
|
if (!await configFile.exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final contents = await configFile.readAsString();
|
||||||
|
final sanitizedContents = contents.replaceAll(
|
||||||
|
RegExp(
|
||||||
|
r'^[ \t]*SET[ \t]+accountName(?:[ \t]+.*)?(?:\r\n|\n|\r|$)',
|
||||||
|
caseSensitive: false,
|
||||||
|
multiLine: true,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sanitizedContents != contents) {
|
||||||
|
await configFile.writeAsString(sanitizedContents, flush: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<GameProcessHandle> launchGame(
|
||||||
|
String installationDir, {
|
||||||
|
required LauncherGameTicket authorization,
|
||||||
|
}) async {
|
||||||
final executablePath = getExecutablePath(installationDir);
|
final executablePath = getExecutablePath(installationDir);
|
||||||
final executable = File(executablePath);
|
final executable = File(executablePath);
|
||||||
|
|
||||||
@@ -226,10 +254,29 @@ class GameInstallationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return startGameProcess(
|
||||||
|
executablePath: executablePath,
|
||||||
|
arguments: const <String>[],
|
||||||
|
workingDirectory: installationDir,
|
||||||
|
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(
|
final process = await Process.start(
|
||||||
executablePath,
|
executablePath,
|
||||||
const <String>[],
|
arguments,
|
||||||
workingDirectory: installationDir,
|
workingDirectory: workingDirectory,
|
||||||
|
environment: environment,
|
||||||
|
includeParentEnvironment: true,
|
||||||
mode: ProcessStartMode.normal,
|
mode: ProcessStartMode.normal,
|
||||||
);
|
);
|
||||||
await process.stdin.close();
|
await process.stdin.close();
|
||||||
@@ -343,6 +390,9 @@ Future<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
|
|||||||
.whereType<String>()
|
.whereType<String>()
|
||||||
.map((entry) => entry.toLowerCase())
|
.map((entry) => entry.toLowerCase())
|
||||||
.toSet();
|
.toSet();
|
||||||
|
final serverFiles = (message['serverFiles'] as List<Object?>)
|
||||||
|
.whereType<String>()
|
||||||
|
.toSet();
|
||||||
|
|
||||||
final rootDirectory = Directory(installationDir);
|
final rootDirectory = Directory(installationDir);
|
||||||
if (!await rootDirectory.exists()) {
|
if (!await rootDirectory.exists()) {
|
||||||
@@ -356,6 +406,7 @@ Future<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
|
|||||||
rootDirectory.path,
|
rootDirectory.path,
|
||||||
pendingFiles,
|
pendingFiles,
|
||||||
ignoredDirectories,
|
ignoredDirectories,
|
||||||
|
serverFiles,
|
||||||
);
|
);
|
||||||
|
|
||||||
pendingFiles.sort(
|
pendingFiles.sort(
|
||||||
@@ -424,41 +475,21 @@ Future<void> _collectFilesInIsolate(
|
|||||||
String rootPath,
|
String rootPath,
|
||||||
List<_SerializablePendingFile> pendingFiles,
|
List<_SerializablePendingFile> pendingFiles,
|
||||||
Set<String> ignoredDirectories,
|
Set<String> ignoredDirectories,
|
||||||
|
Set<String> serverFiles,
|
||||||
) async {
|
) async {
|
||||||
await for (final entity in directory.list(followLinks: false)) {
|
for (final path in serverFiles) {
|
||||||
final relativePath = normalizeClientPath(
|
final file = File('$rootPath/$path');
|
||||||
p.relative(entity.path, from: rootPath),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (relativePath.isEmpty || relativePath == '.') {
|
if (!await file.exists()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final segments = p.posix.split(relativePath);
|
final stat = await file.stat();
|
||||||
if (segments.isNotEmpty &&
|
|
||||||
ignoredDirectories.contains(segments.first.toLowerCase())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entity is Directory) {
|
|
||||||
await _collectFilesInIsolate(
|
|
||||||
entity,
|
|
||||||
rootPath,
|
|
||||||
pendingFiles,
|
|
||||||
ignoredDirectories,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entity is! File) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
final stat = await entity.stat();
|
|
||||||
pendingFiles.add(
|
pendingFiles.add(
|
||||||
_SerializablePendingFile(
|
_SerializablePendingFile(
|
||||||
path: entity.path,
|
path: file.path,
|
||||||
relativePath: relativePath,
|
relativePath: normalizeClientPath(p.relative(path)),
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
modifiedMs: stat.modified.millisecondsSinceEpoch,
|
modifiedMs: stat.modified.millisecondsSinceEpoch,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:moonwell_launcher/features/launcher/domain/entities/client_manif
|
|||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_account.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_account.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
|
||||||
@@ -162,6 +163,47 @@ class LauncherApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<LauncherGameTicket> issueGameTicket(String accessToken) async {
|
||||||
|
final ticketUri = _resolveUri('api/launcher/game-ticket');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _dio.postUri(
|
||||||
|
ticketUri,
|
||||||
|
data: const <String, Object?>{'client_build': 12340},
|
||||||
|
options: _authorizedOptions(accessToken),
|
||||||
|
);
|
||||||
|
final ticket = LauncherGameTicket.fromJson(_coerceMap(response.data));
|
||||||
|
if (!ticket.isUsable()) {
|
||||||
|
throw const LauncherApiException(
|
||||||
|
'Launcher API returned an expired game ticket.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ticket;
|
||||||
|
} on DioException catch (error, stackTrace) {
|
||||||
|
await _launcherLogService.error(
|
||||||
|
'Game ticket request failed.',
|
||||||
|
details: <String, Object?>{
|
||||||
|
'endpoint': ticketUri.toString(),
|
||||||
|
'statusCode': error.response?.statusCode,
|
||||||
|
'dioType': error.type.name,
|
||||||
|
},
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
throw LauncherApiException(_extractErrorMessage(error));
|
||||||
|
} on FormatException catch (error, stackTrace) {
|
||||||
|
await _launcherLogService.error(
|
||||||
|
'Launcher API returned an invalid game ticket payload.',
|
||||||
|
details: <String, Object?>{'endpoint': ticketUri.toString()},
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
throw const LauncherApiException(
|
||||||
|
'Launcher API returned an invalid game ticket.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<ClientManifest> fetchManifest(String accessToken) async {
|
Future<ClientManifest> fetchManifest(String accessToken) async {
|
||||||
final manifestUri = _resolveUri('api/launcher/manifest');
|
final manifestUri = _resolveUri('api/launcher/manifest');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-1
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:moonwell_launcher/app/mw_app.dart';
|
import 'package:moonwell_launcher/app/mw_app.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/data/launcher_update_service.dart';
|
import 'package:moonwell_launcher/features/launcher/data/launcher_update_service.dart';
|
||||||
import 'package:moonwell_launcher/service_container.dart';
|
import 'package:moonwell_launcher/service_container.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
@@ -13,6 +14,7 @@ Future<void> main() async {
|
|||||||
|
|
||||||
await configureDependencies(env: 'flutter');
|
await configureDependencies(env: 'flutter');
|
||||||
final launcherUpdateService = getIt<LauncherUpdateService>();
|
final launcherUpdateService = getIt<LauncherUpdateService>();
|
||||||
|
final launcherVersion = await _readLauncherVersion();
|
||||||
|
|
||||||
const windowOptions = WindowOptions(
|
const windowOptions = WindowOptions(
|
||||||
size: Size(1280, 720),
|
size: Size(1280, 720),
|
||||||
@@ -31,5 +33,14 @@ Future<void> main() async {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
runApp(const MoonWellApp());
|
runApp(MoonWellApp(launcherVersion: launcherVersion));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _readLauncherVersion() async {
|
||||||
|
try {
|
||||||
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
|
return packageInfo.version;
|
||||||
|
} catch (_) {
|
||||||
|
return 'dev';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
C:/Users/sindo/AppData/Local/Pub/Cache/hosted/pub.dev/package_info_plus-9.0.1/
|
||||||
@@ -8,6 +8,7 @@ import Foundation
|
|||||||
import auto_updater_macos
|
import auto_updater_macos
|
||||||
import file_picker
|
import file_picker
|
||||||
import flutter_secure_storage_darwin
|
import flutter_secure_storage_darwin
|
||||||
|
import package_info_plus
|
||||||
import screen_retriever_macos
|
import screen_retriever_macos
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
@@ -17,6 +18,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
AutoUpdaterMacosPlugin.register(with: registry.registrar(forPlugin: "AutoUpdaterMacosPlugin"))
|
AutoUpdaterMacosPlugin.register(with: registry.registrar(forPlugin: "AutoUpdaterMacosPlugin"))
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
|
|||||||
+78
-1
@@ -109,6 +109,83 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/launcher/game-ticket": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Launcher Auth"
|
||||||
|
],
|
||||||
|
"summary": "Выпустить одноразовый ticket для входа в игру",
|
||||||
|
"description": "Отзывает предыдущий неиспользованный ticket аккаунта и возвращает временный SRP-пароль. Ticket не сохраняется и действует не более 60 секунд.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"required": [
|
||||||
|
"client_build"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"client_build": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 12340
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Одноразовая игровая авторизация",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"required": [
|
||||||
|
"account",
|
||||||
|
"ticket",
|
||||||
|
"expires_at"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"account": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "PLAYERONE",
|
||||||
|
"maxLength": 17,
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"ticket": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Z0-9]{16}$",
|
||||||
|
"example": "A1B2C3D4E5F6G7H8"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"example": "2026-08-16T12:01:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Launcher-сессия отсутствует или истекла"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Версия клиента не разрешена"
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Слишком много запросов"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"launcherAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/launcher/manifest": {
|
"/api/launcher/manifest": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -702,4 +779,4 @@
|
|||||||
"description": "Launcher Service"
|
"description": "Launcher Service"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -424,6 +424,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.4.0"
|
version: "4.4.0"
|
||||||
|
http:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
http_multi_server:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -592,6 +600,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
package_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: package_info_plus
|
||||||
|
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.0.1"
|
||||||
|
package_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus_platform_interface
|
||||||
|
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.1"
|
||||||
path:
|
path:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.1+2
|
version: 1.0.2+5
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.10.0"
|
sdk: ">=3.10.0"
|
||||||
@@ -47,6 +47,7 @@ dependencies:
|
|||||||
window_manager: ^0.5.1
|
window_manager: ^0.5.1
|
||||||
auto_updater: 1.0.0
|
auto_updater: 1.0.0
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
|
package_info_plus: ^9.0.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:moonwell_launcher/app/design_system/mw_authentication.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';
|
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -81,4 +82,62 @@ void main() {
|
|||||||
|
|
||||||
expect(requestedMode, MwAuthenticationMode.registration);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart';
|
import 'package:moonwell_launcher/app/design_system/mw_launcher_components.dart';
|
||||||
|
import 'package:moonwell_launcher/app/launcher_version.dart';
|
||||||
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -137,6 +138,25 @@ void main() {
|
|||||||
expect(find.text('Проверить файлы'), findsOneWidget);
|
expect(find.text('Проверить файлы'), findsOneWidget);
|
||||||
expect(find.text('Выйти'), findsNothing);
|
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() {}
|
void _noop() {}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
|
import 'package:moonwell_launcher/app/design_system/mw_shells.dart';
|
||||||
|
import 'package:moonwell_launcher/app/launcher_version.dart';
|
||||||
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -24,13 +25,16 @@ void main() {
|
|||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
|
theme: MoonWellTheme.create(MoonWellThemeVariant.forest),
|
||||||
home: MwLoginShell(
|
home: LauncherVersionScope(
|
||||||
form: const SizedBox(),
|
version: '9.8.7',
|
||||||
onDrag: _noop,
|
child: MwLoginShell(
|
||||||
onDoubleTap: _noop,
|
form: const SizedBox(),
|
||||||
onMinimize: _noop,
|
onDrag: _noop,
|
||||||
onMaximizeRestore: _noop,
|
onDoubleTap: _noop,
|
||||||
onClose: _noop,
|
onMinimize: _noop,
|
||||||
|
onMaximizeRestore: _noop,
|
||||||
|
onClose: _noop,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -38,6 +42,7 @@ void main() {
|
|||||||
|
|
||||||
final image = tester.widget<Image>(find.byType(Image));
|
final image = tester.widget<Image>(find.byType(Image));
|
||||||
expect((image.image as AssetImage).assetName, 'assets/login_key_art.png');
|
expect((image.image as AssetImage).assetName, 'assets/login_key_art.png');
|
||||||
|
expect(find.textContaining('9.8.7'), findsOneWidget);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import 'package:moonwell_launcher/features/downloader/domain/entities/download_p
|
|||||||
import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart';
|
import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
|
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
|
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
|
// import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_account.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_account.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
|
||||||
@@ -147,11 +148,15 @@ void main() {
|
|||||||
final manifest = ClientManifest.fromJson(<String, Object?>{
|
final manifest = ClientManifest.fromJson(<String, Object?>{
|
||||||
'files': <Map<String, Object?>>[],
|
'files': <Map<String, Object?>>[],
|
||||||
});
|
});
|
||||||
final gameService = _TrackingGameInstallationService();
|
final launchOperations = <String>[];
|
||||||
|
final gameService = _TrackingGameInstallationService(launchOperations);
|
||||||
final bloc = HomeScreenBloc(
|
final bloc = HomeScreenBloc(
|
||||||
clientSyncUseCase: _CapturingClientSyncUseCase(),
|
clientSyncUseCase: _CapturingClientSyncUseCase(),
|
||||||
gameInstallationService: gameService,
|
gameInstallationService: gameService,
|
||||||
launcherApiClient: _FakeLauncherApiClient(newsItems: const []),
|
launcherApiClient: _FakeLauncherApiClient(
|
||||||
|
newsItems: const [],
|
||||||
|
launchOperations: launchOperations,
|
||||||
|
),
|
||||||
preferencesRepository: _FakePreferencesRepository(
|
preferencesRepository: _FakePreferencesRepository(
|
||||||
outputDir: Uri.directory('C:/World of Warcraft'),
|
outputDir: Uri.directory('C:/World of Warcraft'),
|
||||||
),
|
),
|
||||||
@@ -168,6 +173,15 @@ void main() {
|
|||||||
await pumpEventQueue(times: 20);
|
await pumpEventQueue(times: 20);
|
||||||
|
|
||||||
expect(gameService.launchCount, 1);
|
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.isGameRunning, isTrue);
|
||||||
expect(bloc.state.model.canPlay, isFalse);
|
expect(bloc.state.model.canPlay, isFalse);
|
||||||
|
|
||||||
@@ -227,13 +241,16 @@ class _FakeLauncherApiClient extends LauncherApiClient {
|
|||||||
this.manifest,
|
this.manifest,
|
||||||
this.realms = const [],
|
this.realms = const [],
|
||||||
this.account = const LauncherAccount(username: ''),
|
this.account = const LauncherAccount(username: ''),
|
||||||
|
this.launchOperations,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<LauncherNewsItem> newsItems;
|
final List<LauncherNewsItem> newsItems;
|
||||||
final ClientManifest? manifest;
|
final ClientManifest? manifest;
|
||||||
final List<LauncherRealm> realms;
|
final List<LauncherRealm> realms;
|
||||||
final LauncherAccount account;
|
final LauncherAccount account;
|
||||||
|
final List<String>? launchOperations;
|
||||||
int manifestRequestCount = 0;
|
int manifestRequestCount = 0;
|
||||||
|
int gameTicketRequestCount = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<LauncherNewsItem>> fetchNews(String accessToken) async =>
|
Future<List<LauncherNewsItem>> fetchNews(String accessToken) async =>
|
||||||
@@ -245,6 +262,17 @@ class _FakeLauncherApiClient extends LauncherApiClient {
|
|||||||
@override
|
@override
|
||||||
Future<LauncherAccount> fetchAccount(String accessToken) async => account;
|
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
|
@override
|
||||||
Future<ClientManifest> fetchManifest(String accessToken) async {
|
Future<ClientManifest> fetchManifest(String accessToken) async {
|
||||||
manifestRequestCount += 1;
|
manifestRequestCount += 1;
|
||||||
@@ -259,29 +287,47 @@ class _FakeGameInstallationService extends GameInstallationService {
|
|||||||
@override
|
@override
|
||||||
Future<bool> hasClientExecutable(String installationDir) async => false;
|
Future<bool> hasClientExecutable(String installationDir) async => false;
|
||||||
|
|
||||||
@override
|
// @override
|
||||||
Future<ClientInstallationSnapshot> scanInstallation(
|
// Future<ClientInstallationSnapshot> scanInstallation(
|
||||||
String installationDir, {
|
// String installationDir, {
|
||||||
InstallationScanProgressCallback? onProgress,
|
// InstallationScanProgressCallback? onProgress,
|
||||||
FutureOr<bool> Function()? isCancelled,
|
// FutureOr<bool> Function()? isCancelled,
|
||||||
}) async {
|
// }) async {
|
||||||
return ClientInstallationSnapshot.fromFiles(const []);
|
// return ClientInstallationSnapshot.fromFiles(const []);
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TrackingGameInstallationService extends _FakeGameInstallationService {
|
class _TrackingGameInstallationService extends _FakeGameInstallationService {
|
||||||
|
_TrackingGameInstallationService(this.launchOperations);
|
||||||
|
|
||||||
|
final List<String> launchOperations;
|
||||||
final Completer<int> exitCode = Completer<int>();
|
final Completer<int> exitCode = Completer<int>();
|
||||||
int launchCount = 0;
|
int launchCount = 0;
|
||||||
|
int savedAccountNameRemovalCount = 0;
|
||||||
|
LauncherGameTicket? launchedWith;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> hasClientExecutable(String installationDir) async => true;
|
Future<bool> hasClientExecutable(String installationDir) async => true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> clearCache(String installationDir) async {}
|
Future<void> removeSavedAccountName(String installationDir) async {
|
||||||
|
launchOperations.add('removeSavedAccountName');
|
||||||
|
savedAccountNameRemovalCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<GameProcessHandle> launchGame(String installationDir) async {
|
Future<void> clearCache(String installationDir) async {
|
||||||
|
launchOperations.add('clearCache');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<GameProcessHandle> launchGame(
|
||||||
|
String installationDir, {
|
||||||
|
required LauncherGameTicket authorization,
|
||||||
|
}) async {
|
||||||
|
launchOperations.add('launchGame');
|
||||||
launchCount += 1;
|
launchCount += 1;
|
||||||
|
launchedWith = authorization;
|
||||||
return GameProcessHandle(pid: 42, exitCode: exitCode.future);
|
return GameProcessHandle(pid: 42, exitCode: exitCode.future);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
// import 'dart:collection';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
|
// import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart';
|
import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
|
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
|
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
|
||||||
@@ -10,139 +10,139 @@ import 'package:moonwell_launcher/features/launcher/data/launcher_log_service.da
|
|||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
|
// import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
|
// import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('ClientSyncUseCase', () {
|
group('ClientSyncUseCase', () {
|
||||||
test(
|
// test(
|
||||||
'completes without downloads when local snapshot matches manifest',
|
// 'completes without downloads when local snapshot matches manifest',
|
||||||
() async {
|
// () async {
|
||||||
final manifest = _manifestFromFiles([
|
// final manifest = _manifestFromFiles([
|
||||||
const ClientManifestFile(
|
// const ClientManifestFile(
|
||||||
path: 'Wow.exe',
|
// path: 'Wow.exe',
|
||||||
size: 5,
|
// size: 5,
|
||||||
sha256: 'wow-hash',
|
// sha256: 'wow-hash',
|
||||||
),
|
// ),
|
||||||
const ClientManifestFile(
|
// const ClientManifestFile(
|
||||||
path: 'Data/common.MPQ',
|
// path: 'Data/common.MPQ',
|
||||||
size: 10,
|
// size: 10,
|
||||||
sha256: 'data-hash',
|
// sha256: 'data-hash',
|
||||||
),
|
// ),
|
||||||
]);
|
// ]);
|
||||||
final matchingSnapshot = ClientInstallationSnapshot.fromFiles([
|
// final matchingSnapshot = ClientInstallationSnapshot.fromFiles([
|
||||||
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
|
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
|
||||||
const LocalClientFile(
|
// const LocalClientFile(
|
||||||
path: 'Data/common.MPQ',
|
// path: 'Data/common.MPQ',
|
||||||
size: 10,
|
// size: 10,
|
||||||
sha256: 'data-hash',
|
// sha256: 'data-hash',
|
||||||
),
|
// ),
|
||||||
]);
|
// ]);
|
||||||
|
|
||||||
final api = _FakeLauncherApiClient(manifest);
|
// final api = _FakeLauncherApiClient(manifest);
|
||||||
final installationService = _FakeGameInstallationService([
|
// final installationService = _FakeGameInstallationService([
|
||||||
matchingSnapshot,
|
// matchingSnapshot,
|
||||||
]);
|
// ]);
|
||||||
final logger = _FakeLauncherLogService();
|
// final logger = _FakeLauncherLogService();
|
||||||
final useCase = ClientSyncUseCase(
|
// final useCase = ClientSyncUseCase(
|
||||||
launcherApiClient: api,
|
// launcherApiClient: api,
|
||||||
installationService: installationService,
|
// installationService: installationService,
|
||||||
launcherLogService: logger,
|
// launcherLogService: logger,
|
||||||
);
|
// );
|
||||||
|
|
||||||
final statuses = await useCase
|
// final statuses = await useCase
|
||||||
.call(
|
// .call(
|
||||||
ClientSyncUseCaseInput(
|
// ClientSyncUseCaseInput(
|
||||||
request: ClientSyncRequest(
|
// request: ClientSyncRequest(
|
||||||
installationDir: 'C:/MoonWell',
|
// installationDir: 'C:/MoonWell',
|
||||||
accessToken: 'token',
|
// accessToken: 'token',
|
||||||
manifest: manifest,
|
// manifest: manifest,
|
||||||
),
|
// ),
|
||||||
),
|
// ),
|
||||||
)
|
// )
|
||||||
.toList();
|
// .toList();
|
||||||
|
|
||||||
expect(statuses.last.stage, ClientSyncStage.completed);
|
// expect(statuses.last.stage, ClientSyncStage.completed);
|
||||||
expect(statuses.last.message, 'Клиент обновлён.');
|
// expect(statuses.last.message, 'Клиент обновлён.');
|
||||||
expect(api.downloadedPaths, isEmpty);
|
// expect(api.downloadedPaths, isEmpty);
|
||||||
expect(installationService.deletedRelativeFiles, isEmpty);
|
// expect(installationService.deletedRelativeFiles, isEmpty);
|
||||||
expect(logger.errorMessages, isEmpty);
|
// expect(logger.errorMessages, isEmpty);
|
||||||
},
|
// },
|
||||||
);
|
// );
|
||||||
|
|
||||||
test(
|
// test(
|
||||||
'removes stale files and downloads changed files before final verify',
|
// 'removes stale files and downloads changed files before final verify',
|
||||||
() async {
|
// () async {
|
||||||
final manifest = _manifestFromFiles([
|
// final manifest = _manifestFromFiles([
|
||||||
const ClientManifestFile(
|
// const ClientManifestFile(
|
||||||
path: 'Wow.exe',
|
// path: 'Wow.exe',
|
||||||
size: 5,
|
// size: 5,
|
||||||
sha256: 'wow-hash',
|
// sha256: 'wow-hash',
|
||||||
),
|
// ),
|
||||||
const ClientManifestFile(
|
// const ClientManifestFile(
|
||||||
path: 'Data/common.MPQ',
|
// path: 'Data/common.MPQ',
|
||||||
size: 10,
|
// size: 10,
|
||||||
sha256: 'data-hash',
|
// sha256: 'data-hash',
|
||||||
),
|
// ),
|
||||||
]);
|
// ]);
|
||||||
final initialSnapshot = ClientInstallationSnapshot.fromFiles([
|
// final initialSnapshot = ClientInstallationSnapshot.fromFiles([
|
||||||
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'old-wow'),
|
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'old-wow'),
|
||||||
const LocalClientFile(path: 'legacy.txt', size: 2, sha256: 'legacy'),
|
// const LocalClientFile(path: 'legacy.txt', size: 2, sha256: 'legacy'),
|
||||||
]);
|
// ]);
|
||||||
final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([
|
// final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([
|
||||||
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
|
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
|
||||||
const LocalClientFile(
|
// const LocalClientFile(
|
||||||
path: 'Data/common.MPQ',
|
// path: 'Data/common.MPQ',
|
||||||
size: 10,
|
// size: 10,
|
||||||
sha256: 'data-hash',
|
// sha256: 'data-hash',
|
||||||
),
|
// ),
|
||||||
]);
|
// ]);
|
||||||
|
|
||||||
final api = _FakeLauncherApiClient(manifest);
|
// final api = _FakeLauncherApiClient(manifest);
|
||||||
final installationService =
|
// final installationService =
|
||||||
_FakeGameInstallationService([initialSnapshot, verifiedSnapshot])
|
// _FakeGameInstallationService([initialSnapshot, verifiedSnapshot])
|
||||||
..fileSizesByPath['C:/MoonWell/Wow.exe.moonwell.part'] = 5
|
// ..fileSizesByPath['C:/MoonWell/Wow.exe.moonwell.part'] = 5
|
||||||
..computedHashesByPath['C:/MoonWell/Wow.exe.moonwell.part'] =
|
// ..computedHashesByPath['C:/MoonWell/Wow.exe.moonwell.part'] =
|
||||||
'wow-hash'
|
// 'wow-hash'
|
||||||
..fileSizesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
|
// ..fileSizesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
|
||||||
10
|
// 10
|
||||||
..computedHashesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
|
// ..computedHashesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
|
||||||
'data-hash';
|
// 'data-hash';
|
||||||
final logger = _FakeLauncherLogService();
|
// final logger = _FakeLauncherLogService();
|
||||||
final useCase = ClientSyncUseCase(
|
// final useCase = ClientSyncUseCase(
|
||||||
launcherApiClient: api,
|
// launcherApiClient: api,
|
||||||
installationService: installationService,
|
// installationService: installationService,
|
||||||
launcherLogService: logger,
|
// launcherLogService: logger,
|
||||||
);
|
// );
|
||||||
|
|
||||||
final statuses = await useCase
|
// final statuses = await useCase
|
||||||
.call(
|
// .call(
|
||||||
ClientSyncUseCaseInput(
|
// ClientSyncUseCaseInput(
|
||||||
request: ClientSyncRequest(
|
// request: ClientSyncRequest(
|
||||||
installationDir: 'C:/MoonWell',
|
// installationDir: 'C:/MoonWell',
|
||||||
accessToken: 'token',
|
// accessToken: 'token',
|
||||||
manifest: manifest,
|
// manifest: manifest,
|
||||||
),
|
// ),
|
||||||
),
|
// ),
|
||||||
)
|
// )
|
||||||
.toList();
|
// .toList();
|
||||||
|
|
||||||
expect(
|
// expect(
|
||||||
statuses.map((status) => status.stage),
|
// statuses.map((status) => status.stage),
|
||||||
containsAll([
|
// containsAll([
|
||||||
ClientSyncStage.removingStaleFiles,
|
// ClientSyncStage.removingStaleFiles,
|
||||||
ClientSyncStage.downloadingFiles,
|
// ClientSyncStage.downloadingFiles,
|
||||||
ClientSyncStage.verifyingInstallation,
|
// ClientSyncStage.verifyingInstallation,
|
||||||
ClientSyncStage.completed,
|
// ClientSyncStage.completed,
|
||||||
]),
|
// ]),
|
||||||
);
|
// );
|
||||||
expect(installationService.deletedRelativeFiles, ['legacy.txt']);
|
// expect(installationService.deletedRelativeFiles, ['legacy.txt']);
|
||||||
expect(api.downloadedPaths, ['Wow.exe', 'Data/common.MPQ']);
|
// expect(api.downloadedPaths, ['Wow.exe', 'Data/common.MPQ']);
|
||||||
expect(statuses.last.message, 'Клиент готов к игре.');
|
// expect(statuses.last.message, 'Клиент готов к игре.');
|
||||||
expect(logger.errorMessages, isEmpty);
|
// expect(logger.errorMessages, isEmpty);
|
||||||
},
|
// },
|
||||||
);
|
// );
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'preserves failed temporary file and returns a log hint on verification mismatch',
|
'preserves failed temporary file and returns a log hint on verification mismatch',
|
||||||
@@ -243,10 +243,8 @@ class _FakeLauncherApiClient extends LauncherApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _FakeGameInstallationService extends GameInstallationService {
|
class _FakeGameInstallationService extends GameInstallationService {
|
||||||
_FakeGameInstallationService(List<ClientInstallationSnapshot> snapshots)
|
_FakeGameInstallationService(List<ClientInstallationSnapshot> snapshots);
|
||||||
: _snapshots = Queue.of(snapshots);
|
|
||||||
|
|
||||||
final Queue<ClientInstallationSnapshot> _snapshots;
|
|
||||||
final List<String> ensuredParentDirectories = [];
|
final List<String> ensuredParentDirectories = [];
|
||||||
final List<String> deletedRelativeFiles = [];
|
final List<String> deletedRelativeFiles = [];
|
||||||
final List<String> deletedFiles = [];
|
final List<String> deletedFiles = [];
|
||||||
@@ -255,34 +253,34 @@ class _FakeGameInstallationService extends GameInstallationService {
|
|||||||
final Map<String, String> computedHashesByPath = {};
|
final Map<String, String> computedHashesByPath = {};
|
||||||
final Map<String, int?> fileSizesByPath = {};
|
final Map<String, int?> fileSizesByPath = {};
|
||||||
|
|
||||||
@override
|
// @override
|
||||||
Future<ClientInstallationSnapshot> scanInstallation(
|
// Future<ClientInstallationSnapshot> scanInstallation(
|
||||||
String installationDir, {
|
// String installationDir, {
|
||||||
InstallationScanProgressCallback? onProgress,
|
// InstallationScanProgressCallback? onProgress,
|
||||||
FutureOr<bool> Function()? isCancelled,
|
// FutureOr<bool> Function()? isCancelled,
|
||||||
}) async {
|
// }) async {
|
||||||
final snapshot = _snapshots.removeFirst();
|
// final snapshot = _snapshots.removeFirst();
|
||||||
var processedFiles = 0;
|
// var processedFiles = 0;
|
||||||
var processedBytes = 0;
|
// var processedBytes = 0;
|
||||||
|
|
||||||
for (final file in snapshot.files) {
|
// for (final file in snapshot.files) {
|
||||||
processedFiles += 1;
|
// processedFiles += 1;
|
||||||
processedBytes += file.size;
|
// processedBytes += file.size;
|
||||||
onProgress?.call(
|
// onProgress?.call(
|
||||||
DownloadProgress(
|
// DownloadProgress(
|
||||||
speed: 0,
|
// speed: 0,
|
||||||
downloaded: processedBytes,
|
// downloaded: processedBytes,
|
||||||
total: snapshot.files.fold<int>(0, (sum, item) => sum + item.size),
|
// total: snapshot.files.fold<int>(0, (sum, item) => sum + item.size),
|
||||||
eta: Duration.zero,
|
// eta: Duration.zero,
|
||||||
),
|
// ),
|
||||||
file.path,
|
// file.path,
|
||||||
processedFiles,
|
// processedFiles,
|
||||||
snapshot.files.length,
|
// snapshot.files.length,
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
return snapshot;
|
// return snapshot;
|
||||||
}
|
// }
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteRelativeFile(
|
Future<void> deleteRelativeFile(
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:moonwell_launcher/config.dart';
|
// import 'package:moonwell_launcher/config.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
|
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
|
// import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('GameInstallationService', () {
|
group('GameInstallationService', () {
|
||||||
@@ -48,37 +49,37 @@ void main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('scanInstallation ignores excluded directories', () async {
|
// test('scanInstallation ignores excluded directories', () async {
|
||||||
final snapshot = await service.scanInstallation(rootDirectory.path);
|
// final snapshot = await service.scanInstallation(rootDirectory.path);
|
||||||
final paths = snapshot.files.map((file) => file.path).toList()..sort();
|
// final paths = snapshot.files.map((file) => file.path).toList()..sort();
|
||||||
|
|
||||||
expect(paths, ['Data/common.MPQ', 'Wow.exe']);
|
// expect(paths, ['Data/common.MPQ', 'Wow.exe']);
|
||||||
expect(snapshot.buildHash, computeBuildHash(snapshot.files));
|
// expect(snapshot.buildHash, computeBuildHash(snapshot.files));
|
||||||
});
|
// });
|
||||||
|
|
||||||
test(
|
// test(
|
||||||
'scanInstallation creates launcher hash cache outside verification set',
|
// 'scanInstallation creates launcher hash cache outside verification set',
|
||||||
() async {
|
// () async {
|
||||||
await service.scanInstallation(rootDirectory.path);
|
// await service.scanInstallation(rootDirectory.path);
|
||||||
|
|
||||||
final cacheFile = File(service.getHashCachePath(rootDirectory.path));
|
// final cacheFile = File(service.getHashCachePath(rootDirectory.path));
|
||||||
expect(await cacheFile.exists(), isTrue);
|
// expect(await cacheFile.exists(), isTrue);
|
||||||
|
|
||||||
final secondSnapshot = await service.scanInstallation(
|
// final secondSnapshot = await service.scanInstallation(
|
||||||
rootDirectory.path,
|
// rootDirectory.path,
|
||||||
);
|
// );
|
||||||
final paths = secondSnapshot.files.map((file) => file.path).toList()
|
// final paths = secondSnapshot.files.map((file) => file.path).toList()
|
||||||
..sort();
|
// ..sort();
|
||||||
|
|
||||||
expect(paths, ['Data/common.MPQ', 'Wow.exe']);
|
// expect(paths, ['Data/common.MPQ', 'Wow.exe']);
|
||||||
expect(
|
// expect(
|
||||||
await Directory(
|
// await Directory(
|
||||||
'${rootDirectory.path}${Platform.pathSeparator}${Config.launcherMetadataDirectoryName}',
|
// '${rootDirectory.path}${Platform.pathSeparator}${Config.launcherMetadataDirectoryName}',
|
||||||
).exists(),
|
// ).exists(),
|
||||||
isTrue,
|
// isTrue,
|
||||||
);
|
// );
|
||||||
},
|
// },
|
||||||
);
|
// );
|
||||||
|
|
||||||
test('clearCache recreates an empty cache directory', () async {
|
test('clearCache recreates an empty cache directory', () async {
|
||||||
await service.clearCache(rootDirectory.path);
|
await service.clearCache(rootDirectory.path);
|
||||||
@@ -91,6 +92,30 @@ void main() {
|
|||||||
expect(await cacheDirectory.list().isEmpty, isTrue);
|
expect(await cacheDirectory.list().isEmpty, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('removeSavedAccountName removes accountName lines only', () async {
|
||||||
|
final configFile = File(
|
||||||
|
'${rootDirectory.path}${Platform.pathSeparator}WTF${Platform.pathSeparator}Config.wtf',
|
||||||
|
);
|
||||||
|
await configFile.parent.create(recursive: true);
|
||||||
|
await configFile.writeAsString(
|
||||||
|
'SET gxWindow "1"\r\n'
|
||||||
|
'SET accountName "admin#&|𞉀#&|�"\r\n'
|
||||||
|
'set ACCOUNTNAME "another account"\r\n'
|
||||||
|
'SET locale "ruRU"\r\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.removeSavedAccountName(rootDirectory.path);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await configFile.readAsString(),
|
||||||
|
'SET gxWindow "1"\r\nSET locale "ruRU"\r\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeSavedAccountName ignores a missing Config.wtf', () async {
|
||||||
|
await service.removeSavedAccountName(rootDirectory.path);
|
||||||
|
});
|
||||||
|
|
||||||
test('ensureParentDirectoryExists creates nested directories', () async {
|
test('ensureParentDirectoryExists creates nested directories', () async {
|
||||||
final tempPath =
|
final tempPath =
|
||||||
'${rootDirectory.path}${Platform.pathSeparator}Data${Platform.pathSeparator}patches${Platform.pathSeparator}common-2.MPQ.moonwell.part';
|
'${rootDirectory.path}${Platform.pathSeparator}Data${Platform.pathSeparator}patches${Platform.pathSeparator}common-2.MPQ.moonwell.part';
|
||||||
@@ -125,5 +150,45 @@ void main() {
|
|||||||
throwsA(isA<LauncherSyncException>()),
|
throwsA(isA<LauncherSyncException>()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'launchGame passes authorization only through child environment',
|
||||||
|
() async {
|
||||||
|
final recordingService = _RecordingGameInstallationService();
|
||||||
|
final authorization = LauncherGameTicket(
|
||||||
|
account: 'PLAYERONE',
|
||||||
|
ticket: 'A1B2C3D4E5F6G7H8',
|
||||||
|
expiresAt: DateTime.now().toUtc().add(const Duration(seconds: 60)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await recordingService.launchGame(
|
||||||
|
rootDirectory.path,
|
||||||
|
authorization: authorization,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(recordingService.arguments, isEmpty);
|
||||||
|
expect(recordingService.environment, <String, String>{
|
||||||
|
'MOONWELL_LAUNCH_ACCOUNT': 'PLAYERONE',
|
||||||
|
'MOONWELL_LAUNCH_TICKET': 'A1B2C3D4E5F6G7H8',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class _RecordingGameInstallationService extends GameInstallationService {
|
||||||
|
Map<String, String>? environment;
|
||||||
|
List<String>? arguments;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<GameProcessHandle> startGameProcess({
|
||||||
|
required String executablePath,
|
||||||
|
required List<String> arguments,
|
||||||
|
required String workingDirectory,
|
||||||
|
required Map<String, String> environment,
|
||||||
|
}) async {
|
||||||
|
this.arguments = List<String>.from(arguments);
|
||||||
|
this.environment = Map<String, String>.from(environment);
|
||||||
|
return GameProcessHandle(pid: 42, exitCode: Future<int>.value(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
+85
-41
@@ -14,6 +14,9 @@ Fails unless the X.Y.Z part of pubspec.yaml matches this value.
|
|||||||
.PARAMETER ReleaseNotes
|
.PARAMETER ReleaseNotes
|
||||||
Text written to the AppCast item description.
|
Text written to the AppCast item description.
|
||||||
|
|
||||||
|
.PARAMETER EnvFile
|
||||||
|
Local dotenv file used to fill environment variables that are not already set.
|
||||||
|
|
||||||
.PARAMETER DryRun
|
.PARAMETER DryRun
|
||||||
Builds, signs, and verifies local artifacts without changing production.
|
Builds, signs, and verifies local artifacts without changing production.
|
||||||
|
|
||||||
@@ -42,6 +45,7 @@ param(
|
|||||||
[string]$S3Endpoint,
|
[string]$S3Endpoint,
|
||||||
[string]$S3Bucket,
|
[string]$S3Bucket,
|
||||||
[string]$S3Key = 'moonwell_launcher_setup.exe',
|
[string]$S3Key = 'moonwell_launcher_setup.exe',
|
||||||
|
[string]$EnvFile = '.env',
|
||||||
[string]$PrivateKeyPath = '.moonwell_signing\dsa_priv.pem',
|
[string]$PrivateKeyPath = '.moonwell_signing\dsa_priv.pem',
|
||||||
[string]$PublicKeyPath = 'windows\runner\resources\dsa_pub.pem',
|
[string]$PublicKeyPath = 'windows\runner\resources\dsa_pub.pem',
|
||||||
[string]$FlutterCommand = 'flutter',
|
[string]$FlutterCommand = 'flutter',
|
||||||
@@ -101,6 +105,64 @@ function Get-FileSha256 {
|
|||||||
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
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 {
|
function Get-AppcastVersion {
|
||||||
param([Parameter(Mandatory = $true)][string]$Path)
|
param([Parameter(Mandatory = $true)][string]$Path)
|
||||||
|
|
||||||
@@ -117,6 +179,7 @@ function Write-Appcast {
|
|||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)][string]$Path,
|
[Parameter(Mandatory = $true)][string]$Path,
|
||||||
[Parameter(Mandatory = $true)][string]$Version,
|
[Parameter(Mandatory = $true)][string]$Version,
|
||||||
|
[Parameter(Mandatory = $true)][string]$BuildVersion,
|
||||||
[Parameter(Mandatory = $true)][string]$Notes,
|
[Parameter(Mandatory = $true)][string]$Notes,
|
||||||
[Parameter(Mandatory = $true)][string]$InstallerUrl,
|
[Parameter(Mandatory = $true)][string]$InstallerUrl,
|
||||||
[Parameter(Mandatory = $true)][string]$Signature,
|
[Parameter(Mandatory = $true)][string]$Signature,
|
||||||
@@ -167,7 +230,7 @@ function Write-Appcast {
|
|||||||
'sparkle',
|
'sparkle',
|
||||||
'version',
|
'version',
|
||||||
$script:SparkleNamespace,
|
$script:SparkleNamespace,
|
||||||
$Version
|
$BuildVersion
|
||||||
)
|
)
|
||||||
$writer.WriteAttributeString(
|
$writer.WriteAttributeString(
|
||||||
'sparkle',
|
'sparkle',
|
||||||
@@ -282,6 +345,14 @@ $previousLocation = Get-Location
|
|||||||
try {
|
try {
|
||||||
Set-Location $repositoryRoot
|
Set-Location $repositoryRoot
|
||||||
|
|
||||||
|
$resolvedEnvFile = if ([IO.Path]::IsPathRooted($EnvFile)) {
|
||||||
|
$EnvFile
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Join-Path $repositoryRoot $EnvFile
|
||||||
|
}
|
||||||
|
Import-DotEnv -Path $resolvedEnvFile
|
||||||
|
|
||||||
if ([string]::IsNullOrWhiteSpace($S3Endpoint)) {
|
if ([string]::IsNullOrWhiteSpace($S3Endpoint)) {
|
||||||
$S3Endpoint = $env:AWS_ENDPOINT
|
$S3Endpoint = $env:AWS_ENDPOINT
|
||||||
}
|
}
|
||||||
@@ -399,9 +470,10 @@ try {
|
|||||||
-UseBasicParsing `
|
-UseBasicParsing `
|
||||||
-OutFile $publishedPath
|
-OutFile $publishedPath
|
||||||
$publishedVersion = Get-AppcastVersion -Path $publishedPath
|
$publishedVersion = Get-AppcastVersion -Path $publishedPath
|
||||||
|
$publishedReleaseVersion = $publishedVersion.Split('+')[0]
|
||||||
if (
|
if (
|
||||||
-not [string]::IsNullOrWhiteSpace($publishedVersion) -and
|
-not [string]::IsNullOrWhiteSpace($publishedVersion) -and
|
||||||
[version]$version -le [version]$publishedVersion
|
[version]$version -le [version]$publishedReleaseVersion
|
||||||
) {
|
) {
|
||||||
throw (
|
throw (
|
||||||
"Version $version is not newer than published version " +
|
"Version $version is not newer than published version " +
|
||||||
@@ -503,6 +575,7 @@ try {
|
|||||||
'/' +
|
'/' +
|
||||||
$S3Key
|
$S3Key
|
||||||
)
|
)
|
||||||
|
$appcastBuildVersion = "$version+$buildNumber"
|
||||||
$appcastPath = if ($DryRun) {
|
$appcastPath = if ($DryRun) {
|
||||||
Join-Path $temporaryDirectory 'appcast.xml'
|
Join-Path $temporaryDirectory 'appcast.xml'
|
||||||
}
|
}
|
||||||
@@ -513,6 +586,7 @@ try {
|
|||||||
Write-Appcast `
|
Write-Appcast `
|
||||||
-Path $appcastPath `
|
-Path $appcastPath `
|
||||||
-Version $version `
|
-Version $version `
|
||||||
|
-BuildVersion $appcastBuildVersion `
|
||||||
-Notes $ReleaseNotes `
|
-Notes $ReleaseNotes `
|
||||||
-InstallerUrl $installerUrl `
|
-InstallerUrl $installerUrl `
|
||||||
-Signature $signature `
|
-Signature $signature `
|
||||||
@@ -522,7 +596,7 @@ try {
|
|||||||
-LiteralPath $appcastPath `
|
-LiteralPath $appcastPath `
|
||||||
-Raw `
|
-Raw `
|
||||||
-Encoding UTF8
|
-Encoding UTF8
|
||||||
if ((Get-AppcastVersion -Path $appcastPath) -ne $version) {
|
if ((Get-AppcastVersion -Path $appcastPath) -ne $appcastBuildVersion) {
|
||||||
throw 'The generated AppCast contains the wrong version.'
|
throw 'The generated AppCast contains the wrong version.'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,45 +617,15 @@ try {
|
|||||||
$env:MOONWELL_RELEASE_KEY = $S3Key
|
$env:MOONWELL_RELEASE_KEY = $S3Key
|
||||||
$env:MOONWELL_RELEASE_VERSION = $version
|
$env:MOONWELL_RELEASE_VERSION = $version
|
||||||
$env:MOONWELL_RELEASE_SHA256 = $installerSha256
|
$env:MOONWELL_RELEASE_SHA256 = $installerSha256
|
||||||
$uploadScript = @'
|
$uploadHelper = Join-Path `
|
||||||
import os
|
$repositoryRoot `
|
||||||
import boto3
|
'tool\upload_launcher_release.py'
|
||||||
from botocore.config import Config
|
if (-not (Test-Path -LiteralPath $uploadHelper -PathType Leaf)) {
|
||||||
|
throw "S3 upload helper was not found: $uploadHelper"
|
||||||
|
}
|
||||||
|
|
||||||
path_style = os.getenv("AWS_USE_PATH_STYLE_ENDPOINT", "true").lower()
|
|
||||||
addressing_style = "path" if path_style in ("1", "true", "yes") else "virtual"
|
|
||||||
s3 = boto3.client(
|
|
||||||
"s3",
|
|
||||||
endpoint_url=os.environ["MOONWELL_RELEASE_ENDPOINT"],
|
|
||||||
region_name=os.getenv("AWS_DEFAULT_REGION", "ru-central1"),
|
|
||||||
config=Config(s3={"addressing_style": addressing_style}),
|
|
||||||
)
|
|
||||||
s3.upload_file(
|
|
||||||
os.environ["MOONWELL_RELEASE_FILE"],
|
|
||||||
os.environ["MOONWELL_RELEASE_BUCKET"],
|
|
||||||
os.environ["MOONWELL_RELEASE_KEY"],
|
|
||||||
ExtraArgs={
|
|
||||||
"ACL": "public-read",
|
|
||||||
"ContentType": "application/x-msdownload",
|
|
||||||
"CacheControl": "no-cache, max-age=0",
|
|
||||||
"Metadata": {
|
|
||||||
"launcher-version": os.environ["MOONWELL_RELEASE_VERSION"],
|
|
||||||
"sha256": os.environ["MOONWELL_RELEASE_SHA256"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response = s3.head_object(
|
|
||||||
Bucket=os.environ["MOONWELL_RELEASE_BUCKET"],
|
|
||||||
Key=os.environ["MOONWELL_RELEASE_KEY"],
|
|
||||||
)
|
|
||||||
print("S3_VERSION_ID=" + str(response.get("VersionId", "")))
|
|
||||||
print("S3_CONTENT_LENGTH=" + str(response["ContentLength"]))
|
|
||||||
'@
|
|
||||||
try {
|
try {
|
||||||
Invoke-RequiredCommand -Command $python -Arguments @(
|
Invoke-RequiredCommand -Command $python -Arguments @($uploadHelper)
|
||||||
'-c',
|
|
||||||
$uploadScript
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
Remove-Item Env:MOONWELL_RELEASE_FILE -ErrorAction SilentlyContinue
|
Remove-Item Env:MOONWELL_RELEASE_FILE -ErrorAction SilentlyContinue
|
||||||
@@ -651,7 +695,7 @@ print("S3_CONTENT_LENGTH=" + str(response["ContentLength"]))
|
|||||||
$publishedEnclosure.GetAttribute(
|
$publishedEnclosure.GetAttribute(
|
||||||
'version',
|
'version',
|
||||||
$script:SparkleNamespace
|
$script:SparkleNamespace
|
||||||
) -ne $version -or
|
) -ne $appcastBuildVersion -or
|
||||||
[long]$publishedEnclosure.length -ne $installer.Length -or
|
[long]$publishedEnclosure.length -ne $installer.Length -or
|
||||||
$publishedEnclosure.url -ne $installerUrl
|
$publishedEnclosure.url -ne $installerUrl
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -35,6 +35,11 @@ target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX")
|
|||||||
target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
|
target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
|
||||||
target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
|
target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
|
||||||
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
|
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
|
||||||
|
target_link_options(
|
||||||
|
${BINARY_NAME}
|
||||||
|
PRIVATE
|
||||||
|
"/MANIFESTUAC:level='requireAdministrator' uiAccess='false'"
|
||||||
|
)
|
||||||
|
|
||||||
# Run the Flutter tool portions of the build. This must not be removed.
|
# Run the Flutter tool portions of the build. This must not be removed.
|
||||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||||
|
|||||||
Reference in New Issue
Block a user