Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32dd46fdf4 | |||
| f834c5fa86 | |||
| d3d19bfaeb |
@@ -6,9 +6,10 @@ change.
|
||||
|
||||
## Project Shape
|
||||
|
||||
MoonWell Launcher is a Flutter desktop app for a private World of Warcraft:
|
||||
Wrath of the Lich King server. It logs players in, downloads and synchronizes
|
||||
client files, displays launcher news, and starts `Wow.exe`.
|
||||
MoonWell Launcher is a Flutter desktop app for a private World of Warcraft
|
||||
1.12.1 server running vMaNGOS. It logs players in, downloads and synchronizes
|
||||
client files, displays launcher news, and starts the Rust MoonWell client
|
||||
(`MoonWell.exe` on Windows or `MoonWell.app` on macOS).
|
||||
|
||||
Important areas:
|
||||
|
||||
@@ -75,6 +76,10 @@ The optional Windows self-update feed is also compile-time configuration:
|
||||
Production AppCast feeds must be public HTTPS URLs. Omit the setting to disable
|
||||
self-update in local builds.
|
||||
|
||||
`MOONWELL_AUTH_ADDRESS` optionally pins the game's auth endpoint as `host[:port]`
|
||||
(default port 3724). Otherwise it is read from `realmlist.wtf` in the selected
|
||||
installation root. A realm's world port is not the auth port.
|
||||
|
||||
Do not commit private credentials, production secrets, temporary bearer tokens,
|
||||
or local-only API URLs.
|
||||
|
||||
@@ -89,10 +94,24 @@ or local-only API URLs.
|
||||
Windows updates use WinSparkle/AppCast and the signed Inno Setup artifact;
|
||||
they are separate from game-client synchronization.
|
||||
- The server manifest is the source of truth for client files.
|
||||
- Sync removes files absent from the manifest under `Data` and `WoW/Data`,
|
||||
plus legacy `WoW.exe` and DLLs directly in the installation root or `WoW`.
|
||||
Also clean obsolete `Extentions` directories in either client root and remove
|
||||
the directory itself when no manifest files require it.
|
||||
Remove unused Data subdirectories only after their children, without recursive
|
||||
deletion. Preserve unrelated files and user state outside these areas.
|
||||
- Language archives under `Data/ruRU` or `WoW/Data/ruRU` use the regular manifest sync.
|
||||
Pass `WOW_LOCALE=auto` to the game so its selected install determines the locale.
|
||||
- Downloads must write to `*.moonwell.part`, verify size and SHA-256, then
|
||||
replace the destination file only after successful verification.
|
||||
- Local scans should ignore volatile client directories and launcher metadata:
|
||||
`Cache`, `Errors`, `Logs`, `Screenshots`, `WTF`, and `.moonwell_launcher`.
|
||||
- MoonWell player state lives in `benilla-config` outside the WoW data and, on
|
||||
macOS, outside the application bundle. Exclude it from verification too.
|
||||
- Request game tickets for build 5875. Pass their account and ticket as
|
||||
`WOW_USER` and `WOW_PASS` in the child environment, never in arguments or
|
||||
persistent storage. Starting MoonWell must not edit `WTF/Config.wtf` or clear
|
||||
the install's `Cache`.
|
||||
- Path normalization and traversal checks are safety-critical. Do not bypass
|
||||
`GameInstallationService.resolveClientPath` for manifest-relative paths.
|
||||
- `lib/service_container.config.dart` is generated by injectable. Do not
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# MoonWell Launcher
|
||||
|
||||
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 1.12.1 server on vMaNGOS, focused on a player-friendly experience
|
||||
without excessive grind and with stronger social play.
|
||||
|
||||
The Windows executable requests administrator privileges on startup so it can
|
||||
@@ -21,6 +21,16 @@ The launcher is responsible for:
|
||||
- displaying the live status of the first configured realm
|
||||
- requesting a single-use game ticket and launching the game client without
|
||||
putting credentials in command-line arguments
|
||||
- starting the Rust MoonWell client on Windows and macOS
|
||||
|
||||
During synchronization, the manifest is authoritative for `Data/` and
|
||||
`WoW/Data/`: files absent from it and unused subdirectories are removed.
|
||||
Legacy `WoW.exe` and DLLs in the installation root or `WoW/` are also removed
|
||||
when absent from the manifest. Required DLLs stay; player settings, addons,
|
||||
logs, screenshots, and other files outside these cleanup areas are preserved.
|
||||
The obsolete `Extentions/` directory in either client root is cleaned the same
|
||||
way and removed when no manifest files require it.
|
||||
Cleanup runs even when all manifest files are already up to date.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -60,6 +70,17 @@ Set the public AppCast feed URL to enable Windows launcher self-updates:
|
||||
The updater is disabled when `MOONWELL_APPCAST_URL` is omitted. Production
|
||||
feeds must use HTTPS.
|
||||
|
||||
The game's auth endpoint is read from `realmlist.wtf` in the selected client
|
||||
folder. For builds that pin an endpoint, set
|
||||
`--dart-define=MOONWELL_AUTH_ADDRESS=login.example:3724` instead. This endpoint is
|
||||
the vMaNGOS auth server, separate from the realm's world port and the Web API.
|
||||
|
||||
Russian 1.12.1 language archives are regular manifest files under `Data/ruRU`
|
||||
(or `WoW/Data/ruRU` for a nested install). They are downloaded and verified with
|
||||
the same size and SHA-256 checks as the base client. The launcher passes
|
||||
`WOW_LOCALE=auto` to the game: an installed pack selects Russian, and a base-only
|
||||
install selects English, regardless of the launcher's inherited environment.
|
||||
|
||||
Example run command:
|
||||
|
||||
```powershell
|
||||
@@ -141,8 +162,26 @@ The launcher sync flow is manifest-based:
|
||||
6. remove stale files outside ignored directories
|
||||
7. download missing or changed files to temporary part files
|
||||
8. verify size and SHA-256 before replacing client files
|
||||
9. request a short-lived game ticket and launch `Wow.exe` from the selected
|
||||
client directory with authorization in its child environment
|
||||
9. request a short-lived game ticket for build 5875 and launch MoonWell from the
|
||||
selected client directory with authorization in its child environment
|
||||
|
||||
The selected folder must contain `MoonWell.exe` on Windows, or `MoonWell.app`
|
||||
on macOS, and your own 1.12.1 data in `Data/` or `WoW/Data/`. macOS launches the
|
||||
bundle's `Contents/MacOS/launch` directly so the launcher keeps a handle to the
|
||||
game process. `benilla-config/` stays beside the EXE or `.app`.
|
||||
|
||||
The ticket's account and temporary password become `WOW_USER` and `WOW_PASS`;
|
||||
the launcher also sets `WOW_HOST`, `WOW_DATA`, and `BENILLA_HOME` for that child
|
||||
only. Launching does not clear the WoW cache or modify `WTF/Config.wtf`.
|
||||
The ticket is consumed by `realmd` after a successful proof; a new login attempt
|
||||
requires a fresh ticket from the launcher.
|
||||
|
||||
In the local WSL deployment, Web API traffic uses the web service's published
|
||||
port 8080, while game authorization and the world use ports 3724 and 8085.
|
||||
The web application writes ticket SRP data to `realmd.launcher_ticket`; the
|
||||
modified auth server consumes it. No additional web/core endpoint is required.
|
||||
Distribute the MoonWell executable and the 1.12.1 data in the client manifest
|
||||
before enabling automatic synchronization for that distribution.
|
||||
|
||||
See `docs/launcher_web_api_spec.md` for the API and sync contract.
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
|
||||
+4
-4
@@ -5,10 +5,10 @@
|
||||
<description>MoonWell Launcher updates</description>
|
||||
<language>ru</language>
|
||||
<item>
|
||||
<title>MoonWell Launcher 1.0.2</title>
|
||||
<description>Исправлена работа с аддонами.</description>
|
||||
<pubDate>Fri, 07 Aug 2026 12:08:41 +04:00</pubDate>
|
||||
<enclosure url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe" sparkle:version="1.0.2+5" sparkle:shortVersionString="1.0.2" sparkle:os="windows" sparkle:dsaSignature="MD4CHQCtVFySsPfkxaSz+nf+vEovupcILFRGQMcEhrsNAh0Ar7GEhqEL2kan6wGXHZ0rfiQqYCzQJ30kXBsrvg==" length="19993275" type="application/octet-stream" />
|
||||
<title>MoonWell Launcher 1.0.7</title>
|
||||
<description>Поддержка русского языкового пакета и очистка устаревших файлов клиента MoonWell.</description>
|
||||
<pubDate>Sat, 10 Oct 2026 17:51:55 +04:00</pubDate>
|
||||
<enclosure url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher/releases/1.0.7/moonwell_launcher_setup.exe" sparkle:version="1.0.7+10" sparkle:shortVersionString="1.0.7" sparkle:os="windows" sparkle:dsaSignature="MDwCHHbQTrZMKzEa5zEXYxAXyLJnyGIHAGWcGj9TbXYCHGkbCgCb+dt3DR5u/ToJyoEtXULYN8cewgbZVHs=" length="20595752" type="application/octet-stream" />
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -8,7 +8,7 @@ This document specifies the current launcher behavior for:
|
||||
- installation directory selection
|
||||
- local file scan and hash calculation
|
||||
- manifest-based synchronization
|
||||
- client launch from `Wow.exe`
|
||||
- launch and game authorization for the Rust MoonWell 1.12.1 client
|
||||
|
||||
The launcher no longer uses S3/MinIO directly. All server interaction goes
|
||||
through the Web API.
|
||||
@@ -18,6 +18,7 @@ through the Web API.
|
||||
The launcher API base URL is provided via:
|
||||
|
||||
- `MOONWELL_API_BASE_URL`
|
||||
- optional `MOONWELL_AUTH_ADDRESS` for the game auth endpoint (`host[:port]`)
|
||||
|
||||
Current integration mode is compile-time:
|
||||
|
||||
@@ -111,9 +112,10 @@ 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.
|
||||
`{ "client_build": 5875 }` 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.
|
||||
`expires_at`. The account is 3–16 uppercase `A-Z0-9` characters. The ticket
|
||||
must remain valid for at least five more seconds, including at process start.
|
||||
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.
|
||||
@@ -135,13 +137,17 @@ Path safety rules:
|
||||
|
||||
## Ignored Directories
|
||||
|
||||
The launcher excludes these top-level directories from verification:
|
||||
Unmanaged files in these directories outside `Data/` and `WoW/Data/` are
|
||||
preserved and excluded from verification:
|
||||
|
||||
- `Cache`
|
||||
- `Errors`
|
||||
- `Logs`
|
||||
- `Screenshots`
|
||||
- `WTF`
|
||||
- `benilla-config`
|
||||
- `.moonwell_launcher`
|
||||
- `Interface/AddOns`
|
||||
|
||||
Files under these directories:
|
||||
|
||||
@@ -149,15 +155,38 @@ Files under these directories:
|
||||
- do not participate in local `buildHash`
|
||||
- are not compared against server manifest
|
||||
|
||||
Inside `Data/` and `WoW/Data/`, the manifest is authoritative for every file,
|
||||
including nested files whose names contain `wtf`, `cache`, or `addons`.
|
||||
The exclusions above do not exempt files inside Data from cleanup.
|
||||
|
||||
## Local Scan
|
||||
|
||||
For every non-ignored file in the installation directory, the launcher computes:
|
||||
For every existing manifest file in the installation directory, the launcher computes:
|
||||
|
||||
- normalized relative path
|
||||
- file size in bytes
|
||||
- file `sha256`
|
||||
|
||||
The result is stored as a `ClientInstallationSnapshot`.
|
||||
The result is stored as a `ClientInstallationSnapshot`. Paths are always relative
|
||||
to the selected installation root, including files in nested directories.
|
||||
|
||||
The scan separately collects stale paths without hashing their contents:
|
||||
|
||||
- every file absent from the manifest in `Data/` and `WoW/Data/`
|
||||
- every file absent from the manifest in obsolete `Extentions/` and
|
||||
`WoW/Extentions/` directories, including unused subdirectories and the
|
||||
Extentions directory itself when no manifest files require it
|
||||
- `WoW.exe` and `*.dll` absent from the manifest directly in the installation
|
||||
root or `WoW/` (binary names are matched without case sensitivity)
|
||||
- unused Data subdirectories, ordered after their children; the Data roots and
|
||||
directories needed by manifest files are retained
|
||||
|
||||
These paths do not contribute to `buildHash`, but prevent sync from completing
|
||||
until cleanup and final verification succeed. Other unmanaged files are preserved.
|
||||
On Windows, manifest membership checks for cleanup ignore path letter case.
|
||||
Directory links are not traversed during cleanup. Links absent from the manifest
|
||||
inside Data are removed as links, and deletion through a parent pointing outside
|
||||
the installation is rejected. Directory deletion is never recursive.
|
||||
|
||||
## Hash Cache
|
||||
|
||||
@@ -221,15 +250,15 @@ The current sync flow is:
|
||||
2. Scan local installation.
|
||||
3. Compute local `buildHash`.
|
||||
4. Compare local files to manifest files by `path`, `size`, and `sha256`.
|
||||
5. Identify stale local files that are not present in the manifest.
|
||||
5. Identify stale paths in the cleanup areas described above.
|
||||
6. If local `buildHash` matches server `buildHash` and there are no stale files
|
||||
and no mismatches, finish successfully.
|
||||
7. Delete stale local files outside ignored directories.
|
||||
7. Delete stale paths, including unused Data subdirectories after their children.
|
||||
8. Download changed and missing files.
|
||||
9. Verify every downloaded file by `sha256`.
|
||||
10. Re-scan the installation.
|
||||
11. Recompute local `buildHash`.
|
||||
12. Fail if final snapshot still differs from manifest.
|
||||
12. Fail if final snapshot still differs from manifest or stale paths remain.
|
||||
|
||||
## Pause and Resume Semantics
|
||||
|
||||
@@ -244,20 +273,38 @@ Pause is implemented as cooperative cancellation:
|
||||
|
||||
When the user presses `Play`:
|
||||
|
||||
1. launcher removes every `SET accountName ...` line from
|
||||
`<install-root>/WTF/Config.wtf` when that file exists
|
||||
2. launcher resolves `<install-root>/Wow.exe`
|
||||
3. launcher clears `<install-root>/Cache`
|
||||
4. launcher requests a single-use game ticket
|
||||
5. launcher starts `Wow.exe` with working directory set to installation root,
|
||||
1. launcher requests a single-use game ticket for build 5875
|
||||
2. launcher resolves `<install-root>/MoonWell.exe` on Windows,
|
||||
`<install-root>/MoonWell.app/Contents/MacOS/launch` on macOS, or
|
||||
`<install-root>/MoonWell` on Linux
|
||||
3. launcher reads the auth address from `MOONWELL_AUTH_ADDRESS` when configured,
|
||||
otherwise from `set realmlist <host[:port]>` in `realmlist.wtf`; this file is
|
||||
never modified and the default auth port is 3724
|
||||
4. launcher resolves the player's data in `Data/`, falling back to `WoW/Data/`,
|
||||
and verifies that the ticket still has at least five seconds of validity
|
||||
5. launcher starts MoonWell with working directory set to the installation root,
|
||||
no authorization command-line arguments, and these child environment values:
|
||||
- `MOONWELL_LAUNCH_ACCOUNT=<account>`
|
||||
- `MOONWELL_LAUNCH_TICKET=<ticket>`
|
||||
- `WOW_USER=<account>`
|
||||
- `WOW_PASS=<ticket>`
|
||||
- `WOW_HOST=<auth-address>`
|
||||
- `WOW_DATA=<absolute-data-directory>`
|
||||
- `BENILLA_HOME=<install-root>/benilla-config`
|
||||
- `WOW_LOCALE=auto` (select the installed `Data/ruRU` or `WoW/Data/ruRU` language pack,
|
||||
otherwise English; override any locale inherited from the launcher's parent)
|
||||
6. launcher retains a process handle; while the process is alive, it disables repeated game launches
|
||||
and client synchronization
|
||||
7. when the process exits, the launcher returns to the ready-to-play state
|
||||
|
||||
If `Wow.exe` is missing, launch fails with an error.
|
||||
Launching never edits `WTF/Config.wtf` or clears `Cache`. macOS executes the
|
||||
bundle launcher directly; that script `exec`s MoonWell so the handle follows
|
||||
the game rather than an `open` helper. Settings remain outside the `.app`.
|
||||
If the executable, data directory, or auth endpoint is missing or invalid,
|
||||
launch fails with an error. A consumed ticket cannot authenticate again; close
|
||||
the client and press Play for a fresh ticket after a session ends.
|
||||
|
||||
Language archives are ordinary client-manifest entries, including their nested paths,
|
||||
sizes and SHA-256 hashes. Synchronization verifies and retains required `ruRU` files
|
||||
and removes obsolete archives under the same manifest-authoritative Data policy.
|
||||
|
||||
## Error Behavior
|
||||
|
||||
@@ -271,7 +318,7 @@ The launcher surfaces errors for:
|
||||
- checksum mismatch after download
|
||||
- missing downloaded temp files before verification
|
||||
- final verification mismatch after update
|
||||
- missing `Wow.exe`
|
||||
- missing MoonWell executable, WoW data, or game auth endpoint
|
||||
- invalid, expired, or unavailable game ticket
|
||||
|
||||
## Tested Invariants
|
||||
@@ -280,8 +327,13 @@ The automated tests cover:
|
||||
|
||||
- deterministic `buildHash`
|
||||
- path normalization used by `buildHash`
|
||||
- exclusion of ignored directories during local scan
|
||||
- preservation of user state and unrelated unmanaged files during cleanup
|
||||
- removal of unmanaged Data files, unused subdirectories, WoW.exe, DLLs, and
|
||||
obsolete Extentions directories
|
||||
- preservation of manifest-listed DLLs and WoW.exe
|
||||
- cache cleanup behavior
|
||||
- safe path resolution
|
||||
- sync flow with fully up-to-date client
|
||||
- sync flow with stale files and changed files
|
||||
- cleanup when all manifest files are already up to date
|
||||
- rejection of stale paths remaining after final verification
|
||||
|
||||
@@ -22,7 +22,7 @@ release that has not passed the remote download and checksum verification.
|
||||
|
||||
The release machine needs:
|
||||
|
||||
- latest stable Flutter and Dart available on `PATH`
|
||||
- latest stable Flutter and Dart selected through FVM
|
||||
- Inno Setup 6, normally installed at
|
||||
`C:\Program Files (x86)\Inno Setup 6\ISCC.exe`
|
||||
- OpenSSL, normally supplied by Git for Windows at
|
||||
@@ -45,6 +45,13 @@ Back up `.moonwell_signing\dsa_priv.pem` in the project secret store. Never
|
||||
commit it, upload it to Object Storage, or send it through chat. Losing this key
|
||||
prevents released launchers from accepting future updates.
|
||||
|
||||
The deploy script copies the x64 Visual C++ CRT DLLs from Visual Studio's
|
||||
redistributable directory into the application folder before compiling the
|
||||
installer. Use `-VcRuntimePath` to override the detected directory. This keeps
|
||||
the per-user installer usable without installing system runtimes as an admin;
|
||||
Windows 10/11 supplies the Universal CRT. See Microsoft's
|
||||
[app-local deployment guidance](https://learn.microsoft.com/en-us/cpp/windows/choosing-a-deployment-method?view=msvc-170).
|
||||
|
||||
## Local `.env`
|
||||
|
||||
For local releases, copy `.env.example` to `.env` and fill in the real values.
|
||||
@@ -125,6 +132,19 @@ The production defaults are:
|
||||
- installer:
|
||||
`https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe`
|
||||
|
||||
Use a versioned `-S3Key`, such as
|
||||
`moonwell_launcher/releases/1.0.4/moonwell_launcher_setup.exe`, to preserve the
|
||||
installer referenced by each signed AppCast. After the feed has been verified,
|
||||
publish the same verified installer at `moonwell_launcher_setup.exe` for the
|
||||
website's stable download link. Set `MOONWELL_CLIENT_OBJECT_KEY` to that key in
|
||||
the web app when its download button should install the launcher. The game
|
||||
files use their separate client manifest and release prefix.
|
||||
|
||||
For FVM, `-FlutterCommand` and `-DartCommand` can point to local `.cmd` wrappers
|
||||
that run `call fvm flutter %*` and `call fvm dart %*` respectively and return
|
||||
`exit /b %errorlevel%`. Keep wrappers inside ignored `build/` and ensure the
|
||||
FVM executable is on `PATH` or use its absolute path in the wrapper.
|
||||
|
||||
On success, the command prints the deployed version, installer URL, SHA-256,
|
||||
and AppCast URL. The tracked root `appcast.xml` contains the exact feed sent to
|
||||
the server.
|
||||
@@ -149,6 +169,10 @@ for all endpoint, bucket, key, and signing-path overrides.
|
||||
|
||||
The script stops on the first failed command.
|
||||
|
||||
External commands are judged by their exit codes. Windows PowerShell may display
|
||||
normal OpenSSL stderr notices as error records; they do not fail a successful
|
||||
command. Key matching and installer-signature verification still must pass.
|
||||
|
||||
- Failure before S3 upload leaves production unchanged.
|
||||
- Failure after S3 upload but before AppCast publication leaves clients on the
|
||||
preceding AppCast. Fix the issue and rerun the same release.
|
||||
|
||||
@@ -526,7 +526,7 @@ class _HeroCopy extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'Wrath of the Lich King · приватный сервер',
|
||||
'World of Warcraft 1.12.1 · приватный сервер',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Cormorant Garamond',
|
||||
fontStyle: FontStyle.italic,
|
||||
|
||||
@@ -351,8 +351,6 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
||||
_gameLaunchPending = true;
|
||||
try {
|
||||
final installationDir = outputPath.toFilePath();
|
||||
await _gameInstallationService.removeSavedAccountName(installationDir);
|
||||
await _gameInstallationService.clearCache(installationDir);
|
||||
final authorization = await _launcherApiClient.issueGameTicket(
|
||||
_session.accessToken,
|
||||
);
|
||||
@@ -381,7 +379,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
||||
model: state.model.copyWith(
|
||||
phase: HomeScreenPhase.readyToPlay,
|
||||
isGameRunning: true,
|
||||
statusText: 'Игра запущена. Папка Cache очищена перед стартом.',
|
||||
statusText: 'MoonWell запущен.',
|
||||
errorMessage: null,
|
||||
),
|
||||
),
|
||||
@@ -418,7 +416,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
||||
isGameRunning: false,
|
||||
statusText: hasClientExecutable
|
||||
? 'Игра закрыта. Клиент готов к запуску.'
|
||||
: 'Игра закрыта, но Wow.exe больше не найден.',
|
||||
: 'Игра закрыта, но клиент MoonWell больше не найден.',
|
||||
errorMessage: null,
|
||||
),
|
||||
),
|
||||
|
||||
+6
-1
@@ -6,7 +6,11 @@ final class Config {
|
||||
'MOONWELL_APPCAST_URL',
|
||||
);
|
||||
static const launcherLogFileName = 'launcher.log';
|
||||
static const gameExecutableName = 'Wow.exe';
|
||||
static const gameExecutableName = 'MoonWell.exe';
|
||||
static const gameClientBuild = 5875;
|
||||
static const gameAuthAddress = String.fromEnvironment(
|
||||
'MOONWELL_AUTH_ADDRESS',
|
||||
);
|
||||
static const cacheDirectoryName = 'Cache';
|
||||
static const launcherMetadataDirectoryName = '.moonwell_launcher';
|
||||
static const hashCacheFileName = 'hash_cache.json';
|
||||
@@ -17,6 +21,7 @@ final class Config {
|
||||
'logs',
|
||||
'screenshots',
|
||||
'wtf',
|
||||
'benilla-config',
|
||||
'.moonwell_launcher',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:moonwell_launcher/config.dart';
|
||||
import 'package:moonwell_launcher/core/use_case.dart';
|
||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
|
||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
|
||||
@@ -117,7 +116,7 @@ class ClientSyncUseCase
|
||||
await _throwIfCancelled(input.isCancelled);
|
||||
|
||||
final filesToDownload = _resolveFilesToDownload(manifest, localSnapshot);
|
||||
final staleFiles = _resolveStaleFiles(manifest, localSnapshot);
|
||||
final staleFiles = localSnapshot.stalePaths;
|
||||
|
||||
_emit(
|
||||
controller,
|
||||
@@ -275,24 +274,6 @@ class ClientSyncUseCase
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<String> _resolveStaleFiles(
|
||||
ClientManifest manifest,
|
||||
ClientInstallationSnapshot snapshot,
|
||||
) {
|
||||
final remoteFiles = manifest.filesByPath;
|
||||
|
||||
return snapshot.files
|
||||
.where((localFile) {
|
||||
if (remoteFiles.containsKey(localFile.path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !_isIgnored(localFile.path);
|
||||
})
|
||||
.map((file) => file.path)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _removeStaleFiles({
|
||||
required StreamController<ClientSyncStatus> controller,
|
||||
required String installationDir,
|
||||
@@ -471,13 +452,7 @@ class ClientSyncUseCase
|
||||
return isMismatched;
|
||||
}).toList();
|
||||
|
||||
final extraFiles = snapshot.files.where((localFile) {
|
||||
if (manifest.filesByPath.containsKey(localFile.path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !_isIgnored(localFile.path);
|
||||
}).toList();
|
||||
final extraFiles = snapshot.stalePaths;
|
||||
|
||||
if (snapshot.buildHash != remoteBuildHash ||
|
||||
mismatchedFiles.isNotEmpty ||
|
||||
@@ -493,14 +468,16 @@ class ClientSyncUseCase
|
||||
.map((file) => file.path)
|
||||
.take(10)
|
||||
.toList(),
|
||||
'extraFiles': extraFiles.map((file) => file.path).take(10).toList(),
|
||||
'extraFiles': extraFiles.take(10).toList(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final firstProblemPath = mismatchedFiles.isNotEmpty
|
||||
? mismatchedFiles.first.path
|
||||
: extraFiles.first.path;
|
||||
: extraFiles.isNotEmpty
|
||||
? extraFiles.first
|
||||
: 'buildHash';
|
||||
throw LauncherSyncException(
|
||||
'Client verification failed after update: $firstProblemPath. '
|
||||
'See ${_launcherLogService.logHint(installationDir)}.',
|
||||
@@ -508,15 +485,6 @@ class ClientSyncUseCase
|
||||
}
|
||||
}
|
||||
|
||||
/// Файлы, которые не будут удалены во время проверки файлов. Например, пользовательские
|
||||
/// аддоны, скриншоты и т.д.
|
||||
bool _isIgnored(String filePath) {
|
||||
final normalizedPath = filePath.replaceAll('\\', '/').toLowerCase();
|
||||
return Config.ignoredVerificationDirectories.any(
|
||||
(pattern) => normalizedPath.contains(pattern.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _verifyDownloadedFile({
|
||||
required String installationDir,
|
||||
required ClientManifestFile file,
|
||||
|
||||
@@ -33,12 +33,20 @@ final class GameProcessHandle {
|
||||
|
||||
@lazySingleton
|
||||
class GameInstallationService {
|
||||
String get operatingSystem => Platform.operatingSystem;
|
||||
|
||||
String get configuredAuthAddress => Config.gameAuthAddress;
|
||||
|
||||
Future<ClientInstallationSnapshot> scanInstallation(
|
||||
String installationDir, {
|
||||
required ClientManifest manifest,
|
||||
InstallationScanProgressCallback? onProgress,
|
||||
FutureOr<bool> Function()? isCancelled,
|
||||
}) async {
|
||||
// Validate the entire manifest before scanning or removing any files.
|
||||
for (final file in manifest.files) {
|
||||
resolveClientPath(installationDir, file.path);
|
||||
}
|
||||
final rootDirectory = Directory(installationDir);
|
||||
if (!await rootDirectory.exists()) {
|
||||
return ClientInstallationSnapshot.fromFiles(const []);
|
||||
@@ -99,7 +107,12 @@ class GameInstallationService {
|
||||
}
|
||||
|
||||
if (type == 'done') {
|
||||
final snapshot = ClientInstallationSnapshot.fromFiles(files);
|
||||
final snapshot = ClientInstallationSnapshot.fromFiles(
|
||||
files,
|
||||
stalePaths: (message['stalePaths'] as List<Object?>)
|
||||
.whereType<String>()
|
||||
.toList(),
|
||||
);
|
||||
completer.complete(snapshot);
|
||||
await cleanup(killIsolate: false);
|
||||
}
|
||||
@@ -134,7 +147,6 @@ class GameInstallationService {
|
||||
'installationDir': installationDir,
|
||||
'hashCachePath': hashCachePath,
|
||||
'cachedHashes': cachedHashes,
|
||||
'ignoredDirectories': Config.ignoredVerificationDirectories.toList(),
|
||||
'serverFiles': manifest.files.map((f) => f.path).toList(),
|
||||
},
|
||||
onError: errorPort.sendPort,
|
||||
@@ -178,9 +190,20 @@ class GameInstallationService {
|
||||
String installationDir,
|
||||
String relativePath,
|
||||
) async {
|
||||
final file = File(resolveClientPath(installationDir, relativePath));
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
final filePath = resolveClientPath(installationDir, relativePath);
|
||||
final type = await FileSystemEntity.type(filePath, followLinks: false);
|
||||
if (type == FileSystemEntityType.notFound) {
|
||||
return;
|
||||
}
|
||||
await _ensureParentInsideInstallation(installationDir, filePath);
|
||||
if (type == FileSystemEntityType.link) {
|
||||
await Link(filePath).delete();
|
||||
} else if (type == FileSystemEntityType.directory) {
|
||||
// Stale directories are collected after their children. Never recursively
|
||||
// delete a directory: files created since the scan must not be lost.
|
||||
await Directory(filePath).delete();
|
||||
} else if (type == FileSystemEntityType.file) {
|
||||
await File(filePath).delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,18 +272,41 @@ class GameInstallationService {
|
||||
final executable = File(executablePath);
|
||||
|
||||
if (!await executable.exists()) {
|
||||
throw LauncherSyncException(
|
||||
'MoonWell client was not found: $executablePath',
|
||||
);
|
||||
}
|
||||
|
||||
final authAddress = await resolveAuthAddress(installationDir);
|
||||
final rootPath = p.absolute(installationDir);
|
||||
final directDataPath = p.join(rootPath, 'Data');
|
||||
final nestedDataPath = p.join(rootPath, 'WoW', 'Data');
|
||||
final dataPath = await Directory(directDataPath).exists()
|
||||
? directDataPath
|
||||
: nestedDataPath;
|
||||
if (!await Directory(dataPath).exists()) {
|
||||
throw const LauncherSyncException(
|
||||
'Wow.exe was not found in the selected folder.',
|
||||
'WoW 1.12.1 Data or WoW/Data was not found in the selected folder.',
|
||||
);
|
||||
}
|
||||
if (!authorization.isUsable()) {
|
||||
throw const LauncherSyncException(
|
||||
'Game ticket has expired. Press Play to request a new ticket.',
|
||||
);
|
||||
}
|
||||
|
||||
return startGameProcess(
|
||||
executablePath: executablePath,
|
||||
arguments: const <String>[],
|
||||
workingDirectory: installationDir,
|
||||
workingDirectory: rootPath,
|
||||
environment: <String, String>{
|
||||
'MOONWELL_LAUNCH_ACCOUNT': authorization.account,
|
||||
'MOONWELL_LAUNCH_TICKET': authorization.ticket,
|
||||
'WOW_USER': authorization.account,
|
||||
'WOW_PASS': authorization.ticket,
|
||||
'WOW_HOST': authAddress,
|
||||
'WOW_DATA': dataPath,
|
||||
// Let the client select Data/ruRU independently of the launcher's parent environment.
|
||||
'WOW_LOCALE': 'auto',
|
||||
'BENILLA_HOME': p.join(rootPath, 'benilla-config'),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -308,7 +354,56 @@ class GameInstallationService {
|
||||
}
|
||||
|
||||
String getExecutablePath(String installationDir) {
|
||||
return p.join(installationDir, Config.gameExecutableName);
|
||||
final rootPath = p.absolute(installationDir);
|
||||
return switch (operatingSystem) {
|
||||
'windows' => p.join(rootPath, Config.gameExecutableName),
|
||||
'macos' => p.join(
|
||||
rootPath,
|
||||
'MoonWell.app',
|
||||
'Contents',
|
||||
'MacOS',
|
||||
'launch',
|
||||
),
|
||||
_ => p.join(rootPath, 'MoonWell'),
|
||||
};
|
||||
}
|
||||
|
||||
/// The auth endpoint is separate from a realm's world port (normally 8085).
|
||||
Future<String> resolveAuthAddress(String installationDir) async {
|
||||
final configured = configuredAuthAddress.trim();
|
||||
if (configured.isNotEmpty) {
|
||||
return _validateAuthAddress(configured);
|
||||
}
|
||||
|
||||
final realmlist = File(p.join(installationDir, 'realmlist.wtf'));
|
||||
if (await realmlist.exists()) {
|
||||
final pattern = RegExp(
|
||||
r'^\s*set\s+realmlist\s+(?:"([^"]+)"|([^\s]+))\s*(?://.*)?$',
|
||||
caseSensitive: false,
|
||||
multiLine: true,
|
||||
);
|
||||
final matches = pattern.allMatches(await realmlist.readAsString());
|
||||
if (matches.isNotEmpty) {
|
||||
final match = matches.last;
|
||||
return _validateAuthAddress((match.group(1) ?? match.group(2)!).trim());
|
||||
}
|
||||
}
|
||||
throw const LauncherSyncException(
|
||||
'Set realmlist in realmlist.wtf or configure MOONWELL_AUTH_ADDRESS.',
|
||||
);
|
||||
}
|
||||
|
||||
String _validateAuthAddress(String address) {
|
||||
final match = RegExp(
|
||||
r'^([A-Za-z0-9][A-Za-z0-9._-]*)(?::([0-9]{1,5}))?$',
|
||||
).firstMatch(address);
|
||||
final port = int.tryParse(match?.group(2) ?? '3724');
|
||||
if (match == null || port == null || port < 1 || port > 65535) {
|
||||
throw const LauncherSyncException(
|
||||
'The auth address must be a hostname or IPv4 address with an optional TCP port.',
|
||||
);
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
String resolveClientPath(String installationDir, String relativePath) {
|
||||
@@ -320,7 +415,9 @@ class GameInstallationService {
|
||||
|
||||
if (pathSegments.isEmpty ||
|
||||
pathSegments.any((segment) => segment == '..') ||
|
||||
p.posix.isAbsolute(normalizedPath)) {
|
||||
pathSegments.any((segment) => segment.contains(':')) ||
|
||||
p.posix.isAbsolute(normalizedPath) ||
|
||||
p.windows.isAbsolute(normalizedPath)) {
|
||||
throw LauncherSyncException('Invalid client file path: $relativePath');
|
||||
}
|
||||
|
||||
@@ -386,28 +483,19 @@ Future<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
|
||||
final cachedHashes = (message['cachedHashes'] as Map<Object?, Object?>).map(
|
||||
(key, value) => MapEntry(key.toString(), value),
|
||||
);
|
||||
final ignoredDirectories = (message['ignoredDirectories'] as List<Object?>)
|
||||
.whereType<String>()
|
||||
.map((entry) => entry.toLowerCase())
|
||||
.toSet();
|
||||
final serverFiles = (message['serverFiles'] as List<Object?>)
|
||||
.whereType<String>()
|
||||
.toSet();
|
||||
|
||||
final rootDirectory = Directory(installationDir);
|
||||
if (!await rootDirectory.exists()) {
|
||||
sendPort.send(<String, Object?>{'type': 'done'});
|
||||
sendPort.send(<String, Object?>{'type': 'done', 'stalePaths': <String>[]});
|
||||
return;
|
||||
}
|
||||
|
||||
final pendingFiles = <_SerializablePendingFile>[];
|
||||
await _collectFilesInIsolate(
|
||||
rootDirectory,
|
||||
rootDirectory.path,
|
||||
pendingFiles,
|
||||
ignoredDirectories,
|
||||
serverFiles,
|
||||
);
|
||||
await _collectManifestFiles(rootDirectory.path, pendingFiles, serverFiles);
|
||||
final stalePaths = await _collectStalePaths(rootDirectory, serverFiles);
|
||||
|
||||
pendingFiles.sort(
|
||||
(left, right) => left.relativePath.compareTo(right.relativePath),
|
||||
@@ -445,7 +533,7 @@ Future<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
|
||||
}
|
||||
|
||||
await _writeHashCache(hashCachePath, updatedCacheEntries);
|
||||
sendPort.send(<String, Object?>{'type': 'done'});
|
||||
sendPort.send(<String, Object?>{'type': 'done', 'stalePaths': stalePaths});
|
||||
}
|
||||
|
||||
Future<String> _resolveFileHash(
|
||||
@@ -470,26 +558,35 @@ Future<String> _resolveFileHash(
|
||||
return digest.toString();
|
||||
}
|
||||
|
||||
Future<void> _collectFilesInIsolate(
|
||||
Directory directory,
|
||||
Future<void> _collectManifestFiles(
|
||||
String rootPath,
|
||||
List<_SerializablePendingFile> pendingFiles,
|
||||
Set<String> ignoredDirectories,
|
||||
Set<String> serverFiles,
|
||||
) async {
|
||||
final installationService = GameInstallationService();
|
||||
final resolvedRoot = await Directory(rootPath).resolveSymbolicLinks();
|
||||
for (final path in serverFiles) {
|
||||
final file = File('$rootPath/$path');
|
||||
final relativePath = normalizeClientPath(path);
|
||||
final file = File(
|
||||
installationService.resolveClientPath(rootPath, relativePath),
|
||||
);
|
||||
|
||||
if (!await file.exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final resolvedFile = await file.resolveSymbolicLinks();
|
||||
if (!p.isWithin(resolvedRoot, resolvedFile)) {
|
||||
throw LauncherSyncException(
|
||||
'Client file points outside installation: $path',
|
||||
);
|
||||
}
|
||||
final stat = await file.stat();
|
||||
|
||||
pendingFiles.add(
|
||||
_SerializablePendingFile(
|
||||
path: file.path,
|
||||
relativePath: normalizeClientPath(p.relative(path)),
|
||||
relativePath: relativePath,
|
||||
size: stat.size,
|
||||
modifiedMs: stat.modified.millisecondsSinceEpoch,
|
||||
),
|
||||
@@ -497,6 +594,88 @@ Future<void> _collectFilesInIsolate(
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureParentInsideInstallation(
|
||||
String installationDir,
|
||||
String filePath,
|
||||
) async {
|
||||
final rootPath = await Directory(installationDir).resolveSymbolicLinks();
|
||||
final parentPath = await File(filePath).parent.resolveSymbolicLinks();
|
||||
if (!p.equals(rootPath, parentPath) && !p.isWithin(rootPath, parentPath)) {
|
||||
throw LauncherSyncException(
|
||||
'Client path points outside installation: $filePath',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>> _collectStalePaths(
|
||||
Directory rootDirectory,
|
||||
Set<String> serverFiles,
|
||||
) async {
|
||||
String pathKey(String path) {
|
||||
final normalized = normalizeClientPath(path);
|
||||
return Platform.isWindows ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
final manifestPaths = serverFiles.map(pathKey).toSet();
|
||||
final manifestDirectories = <String>{};
|
||||
for (final filePath in manifestPaths) {
|
||||
var directory = p.posix.dirname(filePath);
|
||||
while (directory != '.') {
|
||||
manifestDirectories.add(directory);
|
||||
directory = p.posix.dirname(directory);
|
||||
}
|
||||
}
|
||||
final stalePaths = <String>[];
|
||||
|
||||
String relativePath(FileSystemEntity entity) =>
|
||||
normalizeClientPath(p.relative(entity.path, from: rootDirectory.path));
|
||||
|
||||
Future<void> collectStaleDirectory(Directory directory) async {
|
||||
await for (final entity in directory.list(followLinks: false)) {
|
||||
final path = relativePath(entity);
|
||||
if (entity is Directory) {
|
||||
await collectStaleDirectory(entity);
|
||||
if (!manifestDirectories.contains(pathKey(path))) {
|
||||
stalePaths.add(path);
|
||||
}
|
||||
} else if (!manifestPaths.contains(pathKey(path)) &&
|
||||
!(entity is Link && manifestDirectories.contains(pathKey(path)))) {
|
||||
stalePaths.add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> collectClientRoot(
|
||||
Directory directory, {
|
||||
bool nested = false,
|
||||
}) async {
|
||||
await for (final entity in directory.list(followLinks: false)) {
|
||||
final name = p.basename(entity.path).toLowerCase();
|
||||
if (entity is Directory) {
|
||||
if (name == 'data') {
|
||||
await collectStaleDirectory(entity);
|
||||
} else if (name == 'extentions') {
|
||||
await collectStaleDirectory(entity);
|
||||
if (!manifestDirectories.contains(pathKey(relativePath(entity)))) {
|
||||
stalePaths.add(relativePath(entity));
|
||||
}
|
||||
} else if (!nested && name == 'wow') {
|
||||
await collectClientRoot(entity, nested: true);
|
||||
}
|
||||
} else if ((name == 'wow.exe' ||
|
||||
name.endsWith('.dll') ||
|
||||
(entity is Link && name == 'extentions')) &&
|
||||
!manifestPaths.contains(pathKey(relativePath(entity))) &&
|
||||
!manifestDirectories.contains(pathKey(relativePath(entity)))) {
|
||||
stalePaths.add(relativePath(entity));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await collectClientRoot(rootDirectory);
|
||||
return stalePaths;
|
||||
}
|
||||
|
||||
Future<void> _writeHashCache(
|
||||
String hashCachePath,
|
||||
Map<String, Map<String, Object?>> entries,
|
||||
|
||||
@@ -18,6 +18,8 @@ import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_ses
|
||||
|
||||
@lazySingleton
|
||||
class LauncherApiClient {
|
||||
String get apiBaseUrl => Config.launcherApiBaseUrl;
|
||||
|
||||
LauncherApiClient({LauncherLogService? launcherLogService})
|
||||
: _launcherLogService = launcherLogService ?? LauncherLogService(),
|
||||
_dio = Dio(
|
||||
@@ -169,7 +171,7 @@ class LauncherApiClient {
|
||||
try {
|
||||
final response = await _dio.postUri(
|
||||
ticketUri,
|
||||
data: const <String, Object?>{'client_build': 12340},
|
||||
data: const <String, Object?>{'client_build': Config.gameClientBuild},
|
||||
options: _authorizedOptions(accessToken),
|
||||
);
|
||||
final ticket = LauncherGameTicket.fromJson(_coerceMap(response.data));
|
||||
@@ -425,7 +427,7 @@ class LauncherApiClient {
|
||||
}
|
||||
|
||||
Uri _resolveUri(String path) {
|
||||
final rawBaseUrl = Config.launcherApiBaseUrl.trim();
|
||||
final rawBaseUrl = apiBaseUrl.trim();
|
||||
if (rawBaseUrl.isEmpty) {
|
||||
throw const LauncherConfigurationException(
|
||||
'Set MOONWELL_API_BASE_URL before using the launcher API.',
|
||||
|
||||
@@ -5,15 +5,24 @@ final class ClientInstallationSnapshot {
|
||||
final List<LocalClientFile> files;
|
||||
final String buildHash;
|
||||
|
||||
/// Unmanaged cleanup paths, with directories ordered after their children.
|
||||
/// These paths do not contribute to [buildHash].
|
||||
final List<String> stalePaths;
|
||||
|
||||
const ClientInstallationSnapshot({
|
||||
required this.files,
|
||||
required this.buildHash,
|
||||
this.stalePaths = const [],
|
||||
});
|
||||
|
||||
factory ClientInstallationSnapshot.fromFiles(List<LocalClientFile> files) {
|
||||
factory ClientInstallationSnapshot.fromFiles(
|
||||
List<LocalClientFile> files, {
|
||||
List<String> stalePaths = const [],
|
||||
}) {
|
||||
return ClientInstallationSnapshot(
|
||||
files: files,
|
||||
buildHash: computeBuildHash(files),
|
||||
stalePaths: stalePaths,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
final class LauncherGameTicket {
|
||||
static final RegExp _accountPattern = RegExp(r'^[!-<>-~]{1,17}$');
|
||||
static final RegExp _accountPattern = RegExp(r'^[A-Z0-9]{3,16}$');
|
||||
static final RegExp _ticketPattern = RegExp(r'^[A-Z0-9]{16}$');
|
||||
|
||||
final String account;
|
||||
|
||||
+13
-13
@@ -141,10 +141,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -548,26 +548,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.20"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.19.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -929,10 +929,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.12"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1009,10 +1009,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
version: "2.4.3"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1102,5 +1102,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.3 <4.0.0"
|
||||
dart: ">=3.11.0-0 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.2+5
|
||||
version: 1.0.7+10
|
||||
|
||||
environment:
|
||||
sdk: ">=3.10.0"
|
||||
|
||||
@@ -173,13 +173,8 @@ void main() {
|
||||
await pumpEventQueue(times: 20);
|
||||
|
||||
expect(gameService.launchCount, 1);
|
||||
expect(gameService.savedAccountNameRemovalCount, 1);
|
||||
expect(launchOperations, <String>[
|
||||
'removeSavedAccountName',
|
||||
'clearCache',
|
||||
'issueGameTicket',
|
||||
'launchGame',
|
||||
]);
|
||||
expect(gameService.savedAccountNameRemovalCount, 0);
|
||||
expect(launchOperations, <String>['issueGameTicket', 'launchGame']);
|
||||
expect(gameService.launchedWith?.account, 'PLAYERONE');
|
||||
expect(gameService.launchedWith?.ticket, 'A1B2C3D4E5F6G7H8');
|
||||
expect(bloc.state.model.isGameRunning, isTrue);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'dart:async';
|
||||
// import 'dart:collection';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
// import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
|
||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
|
||||
@@ -10,139 +11,275 @@ import 'package:moonwell_launcher/features/launcher/data/launcher_log_service.da
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
|
||||
// import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||
// import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
|
||||
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
|
||||
|
||||
void main() {
|
||||
group('ClientSyncUseCase', () {
|
||||
// test(
|
||||
// 'completes without downloads when local snapshot matches manifest',
|
||||
// () async {
|
||||
// final manifest = _manifestFromFiles([
|
||||
// const ClientManifestFile(
|
||||
// path: 'Wow.exe',
|
||||
// size: 5,
|
||||
// sha256: 'wow-hash',
|
||||
// ),
|
||||
// const ClientManifestFile(
|
||||
// path: 'Data/common.MPQ',
|
||||
// size: 10,
|
||||
// sha256: 'data-hash',
|
||||
// ),
|
||||
// ]);
|
||||
// final matchingSnapshot = ClientInstallationSnapshot.fromFiles([
|
||||
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
|
||||
// const LocalClientFile(
|
||||
// path: 'Data/common.MPQ',
|
||||
// size: 10,
|
||||
// sha256: 'data-hash',
|
||||
// ),
|
||||
// ]);
|
||||
test(
|
||||
'completes without downloads when local snapshot matches manifest',
|
||||
() async {
|
||||
final manifest = _manifestFromFiles([
|
||||
const ClientManifestFile(
|
||||
path: 'MoonWell.exe',
|
||||
size: 5,
|
||||
sha256: 'wow-hash',
|
||||
),
|
||||
const ClientManifestFile(
|
||||
path: 'Data/common.MPQ',
|
||||
size: 10,
|
||||
sha256: 'data-hash',
|
||||
),
|
||||
]);
|
||||
final matchingSnapshot = ClientInstallationSnapshot.fromFiles([
|
||||
const LocalClientFile(
|
||||
path: 'MoonWell.exe',
|
||||
size: 5,
|
||||
sha256: 'wow-hash',
|
||||
),
|
||||
const LocalClientFile(
|
||||
path: 'Data/common.MPQ',
|
||||
size: 10,
|
||||
sha256: 'data-hash',
|
||||
),
|
||||
]);
|
||||
|
||||
// final api = _FakeLauncherApiClient(manifest);
|
||||
// final installationService = _FakeGameInstallationService([
|
||||
// matchingSnapshot,
|
||||
// ]);
|
||||
// final logger = _FakeLauncherLogService();
|
||||
// final useCase = ClientSyncUseCase(
|
||||
// launcherApiClient: api,
|
||||
// installationService: installationService,
|
||||
// launcherLogService: logger,
|
||||
// );
|
||||
final api = _FakeLauncherApiClient(manifest);
|
||||
final installationService = _FakeGameInstallationService([
|
||||
matchingSnapshot,
|
||||
]);
|
||||
final logger = _FakeLauncherLogService();
|
||||
final useCase = ClientSyncUseCase(
|
||||
launcherApiClient: api,
|
||||
installationService: installationService,
|
||||
launcherLogService: logger,
|
||||
);
|
||||
|
||||
// final statuses = await useCase
|
||||
// .call(
|
||||
// ClientSyncUseCaseInput(
|
||||
// request: ClientSyncRequest(
|
||||
// installationDir: 'C:/MoonWell',
|
||||
// accessToken: 'token',
|
||||
// manifest: manifest,
|
||||
// ),
|
||||
// ),
|
||||
// )
|
||||
// .toList();
|
||||
final statuses = await useCase
|
||||
.call(
|
||||
ClientSyncUseCaseInput(
|
||||
request: ClientSyncRequest(
|
||||
installationDir: 'C:/MoonWell',
|
||||
accessToken: 'token',
|
||||
manifest: manifest,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
// expect(statuses.last.stage, ClientSyncStage.completed);
|
||||
// expect(statuses.last.message, 'Клиент обновлён.');
|
||||
// expect(api.downloadedPaths, isEmpty);
|
||||
// expect(installationService.deletedRelativeFiles, isEmpty);
|
||||
// expect(logger.errorMessages, isEmpty);
|
||||
// },
|
||||
// );
|
||||
expect(statuses.last.stage, ClientSyncStage.completed);
|
||||
expect(statuses.last.message, 'Клиент обновлён.');
|
||||
expect(api.downloadedPaths, isEmpty);
|
||||
expect(installationService.deletedRelativeFiles, isEmpty);
|
||||
expect(logger.errorMessages, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
// test(
|
||||
// 'removes stale files and downloads changed files before final verify',
|
||||
// () async {
|
||||
// final manifest = _manifestFromFiles([
|
||||
// const ClientManifestFile(
|
||||
// path: 'Wow.exe',
|
||||
// size: 5,
|
||||
// sha256: 'wow-hash',
|
||||
// ),
|
||||
// const ClientManifestFile(
|
||||
// path: 'Data/common.MPQ',
|
||||
// size: 10,
|
||||
// sha256: 'data-hash',
|
||||
// ),
|
||||
// ]);
|
||||
// final initialSnapshot = ClientInstallationSnapshot.fromFiles([
|
||||
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'old-wow'),
|
||||
// const LocalClientFile(path: 'legacy.txt', size: 2, sha256: 'legacy'),
|
||||
// ]);
|
||||
// final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([
|
||||
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
|
||||
// const LocalClientFile(
|
||||
// path: 'Data/common.MPQ',
|
||||
// size: 10,
|
||||
// sha256: 'data-hash',
|
||||
// ),
|
||||
// ]);
|
||||
test(
|
||||
'removes stale files and downloads changed files before final verify',
|
||||
() async {
|
||||
final manifest = _manifestFromFiles([
|
||||
const ClientManifestFile(
|
||||
path: 'MoonWell.exe',
|
||||
size: 5,
|
||||
sha256: 'wow-hash',
|
||||
),
|
||||
const ClientManifestFile(
|
||||
path: 'Data/common.MPQ',
|
||||
size: 10,
|
||||
sha256: 'data-hash',
|
||||
),
|
||||
]);
|
||||
final initialSnapshot = ClientInstallationSnapshot.fromFiles(
|
||||
[
|
||||
const LocalClientFile(
|
||||
path: 'MoonWell.exe',
|
||||
size: 5,
|
||||
sha256: 'old-wow',
|
||||
),
|
||||
],
|
||||
stalePaths: ['Data/legacy.MPQ', 'WoW.exe', 'old.dll'],
|
||||
);
|
||||
final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([
|
||||
const LocalClientFile(
|
||||
path: 'MoonWell.exe',
|
||||
size: 5,
|
||||
sha256: 'wow-hash',
|
||||
),
|
||||
const LocalClientFile(
|
||||
path: 'Data/common.MPQ',
|
||||
size: 10,
|
||||
sha256: 'data-hash',
|
||||
),
|
||||
]);
|
||||
|
||||
// final api = _FakeLauncherApiClient(manifest);
|
||||
// final installationService =
|
||||
// _FakeGameInstallationService([initialSnapshot, verifiedSnapshot])
|
||||
// ..fileSizesByPath['C:/MoonWell/Wow.exe.moonwell.part'] = 5
|
||||
// ..computedHashesByPath['C:/MoonWell/Wow.exe.moonwell.part'] =
|
||||
// 'wow-hash'
|
||||
// ..fileSizesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
|
||||
// 10
|
||||
// ..computedHashesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
|
||||
// 'data-hash';
|
||||
// final logger = _FakeLauncherLogService();
|
||||
// final useCase = ClientSyncUseCase(
|
||||
// launcherApiClient: api,
|
||||
// installationService: installationService,
|
||||
// launcherLogService: logger,
|
||||
// );
|
||||
final api = _FakeLauncherApiClient(manifest);
|
||||
final installationService =
|
||||
_FakeGameInstallationService([initialSnapshot, verifiedSnapshot])
|
||||
..fileSizesByPath['C:/MoonWell/MoonWell.exe.moonwell.part'] = 5
|
||||
..computedHashesByPath['C:/MoonWell/MoonWell.exe.moonwell.part'] =
|
||||
'wow-hash'
|
||||
..fileSizesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
|
||||
10
|
||||
..computedHashesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
|
||||
'data-hash';
|
||||
final logger = _FakeLauncherLogService();
|
||||
final useCase = ClientSyncUseCase(
|
||||
launcherApiClient: api,
|
||||
installationService: installationService,
|
||||
launcherLogService: logger,
|
||||
);
|
||||
|
||||
// final statuses = await useCase
|
||||
// .call(
|
||||
// ClientSyncUseCaseInput(
|
||||
// request: ClientSyncRequest(
|
||||
// installationDir: 'C:/MoonWell',
|
||||
// accessToken: 'token',
|
||||
// manifest: manifest,
|
||||
// ),
|
||||
// ),
|
||||
// )
|
||||
// .toList();
|
||||
final statuses = await useCase
|
||||
.call(
|
||||
ClientSyncUseCaseInput(
|
||||
request: ClientSyncRequest(
|
||||
installationDir: 'C:/MoonWell',
|
||||
accessToken: 'token',
|
||||
manifest: manifest,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
// expect(
|
||||
// statuses.map((status) => status.stage),
|
||||
// containsAll([
|
||||
// ClientSyncStage.removingStaleFiles,
|
||||
// ClientSyncStage.downloadingFiles,
|
||||
// ClientSyncStage.verifyingInstallation,
|
||||
// ClientSyncStage.completed,
|
||||
// ]),
|
||||
// );
|
||||
// expect(installationService.deletedRelativeFiles, ['legacy.txt']);
|
||||
// expect(api.downloadedPaths, ['Wow.exe', 'Data/common.MPQ']);
|
||||
// expect(statuses.last.message, 'Клиент готов к игре.');
|
||||
// expect(logger.errorMessages, isEmpty);
|
||||
// },
|
||||
// );
|
||||
expect(
|
||||
statuses.map((status) => status.stage),
|
||||
containsAll([
|
||||
ClientSyncStage.removingStaleFiles,
|
||||
ClientSyncStage.downloadingFiles,
|
||||
ClientSyncStage.verifyingInstallation,
|
||||
ClientSyncStage.completed,
|
||||
]),
|
||||
);
|
||||
expect(installationService.deletedRelativeFiles, [
|
||||
'Data/legacy.MPQ',
|
||||
'WoW.exe',
|
||||
'old.dll',
|
||||
]);
|
||||
expect(api.downloadedPaths, ['MoonWell.exe', 'Data/common.MPQ']);
|
||||
expect(statuses.last.message, 'Клиент готов к игре.');
|
||||
expect(logger.errorMessages, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('cleans an up-to-date installation without downloading', () async {
|
||||
final root = await Directory.systemTemp.createTemp('moonwell_sync_');
|
||||
addTearDown(() => root.delete(recursive: true));
|
||||
final service = GameInstallationService();
|
||||
final retained = ['MoonWell.exe', 'Data/common.MPQ', 'required.dll'];
|
||||
final obsolete = [
|
||||
'Data/old/patch.MPQ',
|
||||
'WoW.exe',
|
||||
'unused.DLL',
|
||||
'Extentions/bin/extension.dll',
|
||||
];
|
||||
for (final path in [
|
||||
...retained,
|
||||
...obsolete,
|
||||
'WTF/Config.wtf',
|
||||
'realmlist.wtf',
|
||||
]) {
|
||||
final file = File(service.resolveClientPath(root.path, path));
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsString(path);
|
||||
}
|
||||
final entries = <ClientManifestFile>[];
|
||||
for (final path in retained) {
|
||||
final filePath = service.resolveClientPath(root.path, path);
|
||||
entries.add(
|
||||
ClientManifestFile(
|
||||
path: path,
|
||||
size: (await File(filePath).stat()).size,
|
||||
sha256: await service.computeSha256(filePath),
|
||||
),
|
||||
);
|
||||
}
|
||||
final manifest = _manifestFromFiles(entries);
|
||||
final api = _FakeLauncherApiClient(manifest);
|
||||
final useCase = ClientSyncUseCase(
|
||||
launcherApiClient: api,
|
||||
installationService: service,
|
||||
launcherLogService: _FakeLauncherLogService(),
|
||||
);
|
||||
final statuses = await useCase
|
||||
.call(
|
||||
ClientSyncUseCaseInput(
|
||||
request: ClientSyncRequest(
|
||||
installationDir: root.path,
|
||||
accessToken: 'token',
|
||||
manifest: manifest,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
expect(statuses.last.stage, ClientSyncStage.completed);
|
||||
expect(statuses.last.localBuildHash, manifest.buildHash);
|
||||
expect(
|
||||
statuses.map((status) => status.stage),
|
||||
contains(ClientSyncStage.removingStaleFiles),
|
||||
);
|
||||
expect(api.downloadedPaths, isEmpty);
|
||||
for (final path in [
|
||||
...obsolete,
|
||||
'Data/old',
|
||||
'Extentions/bin',
|
||||
'Extentions',
|
||||
]) {
|
||||
expect(
|
||||
await FileSystemEntity.type(
|
||||
service.resolveClientPath(root.path, path),
|
||||
followLinks: false,
|
||||
),
|
||||
FileSystemEntityType.notFound,
|
||||
reason: path,
|
||||
);
|
||||
}
|
||||
for (final path in [...retained, 'WTF/Config.wtf', 'realmlist.wtf']) {
|
||||
expect(
|
||||
await File(service.resolveClientPath(root.path, path)).readAsString(),
|
||||
path,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('fails final verification when stale files remain', () async {
|
||||
final manifest = _manifestFromFiles([
|
||||
const ClientManifestFile(path: 'MoonWell.exe', size: 5, sha256: 'hash'),
|
||||
]);
|
||||
final snapshot = ClientInstallationSnapshot.fromFiles(
|
||||
[const LocalClientFile(path: 'MoonWell.exe', size: 5, sha256: 'hash')],
|
||||
stalePaths: ['Data/extra.MPQ'],
|
||||
);
|
||||
final service = _FakeGameInstallationService([snapshot, snapshot]);
|
||||
final useCase = ClientSyncUseCase(
|
||||
launcherApiClient: _FakeLauncherApiClient(manifest),
|
||||
installationService: service,
|
||||
launcherLogService: _FakeLauncherLogService(),
|
||||
);
|
||||
await expectLater(
|
||||
useCase
|
||||
.call(
|
||||
ClientSyncUseCaseInput(
|
||||
request: ClientSyncRequest(
|
||||
installationDir: 'C:/MoonWell',
|
||||
accessToken: 'token',
|
||||
manifest: manifest,
|
||||
),
|
||||
),
|
||||
)
|
||||
.drain<void>(),
|
||||
throwsA(
|
||||
isA<LauncherSyncException>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
contains('Data/extra.MPQ'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'preserves failed temporary file and returns a log hint on verification mismatch',
|
||||
@@ -243,7 +380,10 @@ class _FakeLauncherApiClient extends LauncherApiClient {
|
||||
}
|
||||
|
||||
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> deletedRelativeFiles = [];
|
||||
@@ -253,34 +393,35 @@ class _FakeGameInstallationService extends GameInstallationService {
|
||||
final Map<String, String> computedHashesByPath = {};
|
||||
final Map<String, int?> fileSizesByPath = {};
|
||||
|
||||
// @override
|
||||
// Future<ClientInstallationSnapshot> scanInstallation(
|
||||
// String installationDir, {
|
||||
// InstallationScanProgressCallback? onProgress,
|
||||
// FutureOr<bool> Function()? isCancelled,
|
||||
// }) async {
|
||||
// final snapshot = _snapshots.removeFirst();
|
||||
// var processedFiles = 0;
|
||||
// var processedBytes = 0;
|
||||
@override
|
||||
Future<ClientInstallationSnapshot> scanInstallation(
|
||||
String installationDir, {
|
||||
required ClientManifest manifest,
|
||||
InstallationScanProgressCallback? onProgress,
|
||||
FutureOr<bool> Function()? isCancelled,
|
||||
}) async {
|
||||
final snapshot = _snapshots.removeFirst();
|
||||
var processedFiles = 0;
|
||||
var processedBytes = 0;
|
||||
|
||||
// for (final file in snapshot.files) {
|
||||
// processedFiles += 1;
|
||||
// processedBytes += file.size;
|
||||
// onProgress?.call(
|
||||
// DownloadProgress(
|
||||
// speed: 0,
|
||||
// downloaded: processedBytes,
|
||||
// total: snapshot.files.fold<int>(0, (sum, item) => sum + item.size),
|
||||
// eta: Duration.zero,
|
||||
// ),
|
||||
// file.path,
|
||||
// processedFiles,
|
||||
// snapshot.files.length,
|
||||
// );
|
||||
// }
|
||||
for (final file in snapshot.files) {
|
||||
processedFiles += 1;
|
||||
processedBytes += file.size;
|
||||
onProgress?.call(
|
||||
DownloadProgress(
|
||||
speed: 0,
|
||||
downloaded: processedBytes,
|
||||
total: snapshot.files.fold<int>(0, (sum, item) => sum + item.size),
|
||||
eta: Duration.zero,
|
||||
),
|
||||
file.path,
|
||||
processedFiles,
|
||||
snapshot.files.length,
|
||||
);
|
||||
}
|
||||
|
||||
// return snapshot;
|
||||
// }
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteRelativeFile(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
// import 'package:moonwell_launcher/config.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
|
||||
// import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_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';
|
||||
|
||||
@@ -13,14 +13,17 @@ void main() {
|
||||
late GameInstallationService service;
|
||||
|
||||
setUp(() async {
|
||||
service = GameInstallationService();
|
||||
service = _RecordingGameInstallationService();
|
||||
rootDirectory = await Directory.systemTemp.createTemp(
|
||||
'moonwell_launcher_',
|
||||
);
|
||||
|
||||
await File(
|
||||
'${rootDirectory.path}${Platform.pathSeparator}Wow.exe',
|
||||
).writeAsString('wow');
|
||||
service.getExecutablePath(rootDirectory.path),
|
||||
).writeAsString('moonwell');
|
||||
await File(
|
||||
'${rootDirectory.path}${Platform.pathSeparator}realmlist.wtf',
|
||||
).writeAsString('set realmlist 127.0.0.1:3724\n');
|
||||
await Directory(
|
||||
'${rootDirectory.path}${Platform.pathSeparator}Data',
|
||||
).create(recursive: true);
|
||||
@@ -49,37 +52,287 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
// test('scanInstallation ignores excluded directories', () async {
|
||||
// final snapshot = await service.scanInstallation(rootDirectory.path);
|
||||
// final paths = snapshot.files.map((file) => file.path).toList()..sort();
|
||||
test(
|
||||
'finds only unmanaged Data files and legacy client binaries',
|
||||
() async {
|
||||
final retainedPaths = [
|
||||
'MoonWell.exe',
|
||||
'Data/common.MPQ',
|
||||
'Data/ruRU/realmlist.wtf',
|
||||
'Data/Addons/required.MPQ',
|
||||
'required.DLL',
|
||||
'WoW/required.dll',
|
||||
'WoW/Data/common.MPQ',
|
||||
];
|
||||
final staleFiles = [
|
||||
'Data/old.MPQ',
|
||||
'Data/ruRU/old.MPQ',
|
||||
'Data/Addons/extra.MPQ',
|
||||
'Data/Cache/old.bin',
|
||||
'Data/empty/old.MPQ',
|
||||
'Data/common.MPQ.moonwell.part',
|
||||
'Data/broken.MPQ.moonwell.part.failed',
|
||||
'WoW.exe',
|
||||
'old.DLL',
|
||||
'WoW/WoW.exe',
|
||||
'WoW/old.dll',
|
||||
'WoW/Data/old.MPQ',
|
||||
'Extentions/bin/extension.dll',
|
||||
'WoW/Extentions/old.txt',
|
||||
];
|
||||
final untouchedPaths = [
|
||||
'Cache/cache.bin',
|
||||
'Logs/launcher.log',
|
||||
'WTF/Config.wtf',
|
||||
'Errors/error.dll',
|
||||
'Screenshots/shot.dll',
|
||||
'benilla-config/settings.dll',
|
||||
'.moonwell_launcher/local.dll',
|
||||
'Interface/AddOns/custom/addon.dll',
|
||||
'notes.txt',
|
||||
'Other.exe',
|
||||
'realmlist.wtf',
|
||||
];
|
||||
for (final path in [
|
||||
...retainedPaths,
|
||||
...staleFiles,
|
||||
...untouchedPaths,
|
||||
]) {
|
||||
final file = File(
|
||||
service.resolveClientPath(rootDirectory.path, path),
|
||||
);
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsString(path);
|
||||
}
|
||||
await Directory(
|
||||
service.resolveClientPath(rootDirectory.path, 'Data/unused/nested'),
|
||||
).create(recursive: true);
|
||||
await Directory(
|
||||
service.resolveClientPath(rootDirectory.path, 'Extentions/empty'),
|
||||
).create(recursive: true);
|
||||
final manifest = await _manifestForPaths(
|
||||
service,
|
||||
rootDirectory,
|
||||
retainedPaths,
|
||||
);
|
||||
|
||||
// expect(paths, ['Data/common.MPQ', 'Wow.exe']);
|
||||
// expect(snapshot.buildHash, computeBuildHash(snapshot.files));
|
||||
// });
|
||||
final snapshot = await service.scanInstallation(
|
||||
rootDirectory.path,
|
||||
manifest: manifest,
|
||||
);
|
||||
|
||||
// test(
|
||||
// 'scanInstallation creates launcher hash cache outside verification set',
|
||||
// () async {
|
||||
// await service.scanInstallation(rootDirectory.path);
|
||||
expect(
|
||||
snapshot.files.map((file) => file.path),
|
||||
unorderedEquals(retainedPaths),
|
||||
);
|
||||
expect(snapshot.buildHash, manifest.buildHash);
|
||||
expect(
|
||||
snapshot.stalePaths,
|
||||
unorderedEquals([
|
||||
...staleFiles,
|
||||
'Data/Cache',
|
||||
'Data/empty',
|
||||
'Data/unused/nested',
|
||||
'Data/unused',
|
||||
'Extentions/bin',
|
||||
'Extentions/empty',
|
||||
'Extentions',
|
||||
'WoW/Extentions',
|
||||
]),
|
||||
);
|
||||
for (final path in snapshot.stalePaths) {
|
||||
await service.deleteRelativeFile(rootDirectory.path, path);
|
||||
}
|
||||
for (final path in [...retainedPaths, ...untouchedPaths]) {
|
||||
expect(
|
||||
await File(
|
||||
service.resolveClientPath(rootDirectory.path, path),
|
||||
).exists(),
|
||||
isTrue,
|
||||
reason: path,
|
||||
);
|
||||
}
|
||||
for (final path in snapshot.stalePaths) {
|
||||
expect(
|
||||
await FileSystemEntity.type(
|
||||
service.resolveClientPath(rootDirectory.path, path),
|
||||
followLinks: false,
|
||||
),
|
||||
FileSystemEntityType.notFound,
|
||||
reason: path,
|
||||
);
|
||||
}
|
||||
|
||||
// final cacheFile = File(service.getHashCachePath(rootDirectory.path));
|
||||
// expect(await cacheFile.exists(), isTrue);
|
||||
final verified = await service.scanInstallation(
|
||||
rootDirectory.path,
|
||||
manifest: manifest,
|
||||
);
|
||||
expect(verified.stalePaths, isEmpty);
|
||||
expect(verified.buildHash, manifest.buildHash);
|
||||
expect(
|
||||
await File(service.getHashCachePath(rootDirectory.path)).exists(),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// final secondSnapshot = await service.scanInstallation(
|
||||
// rootDirectory.path,
|
||||
// );
|
||||
// final paths = secondSnapshot.files.map((file) => file.path).toList()
|
||||
// ..sort();
|
||||
test('removes Data links without touching their targets', () async {
|
||||
final outside = await Directory.systemTemp.createTemp(
|
||||
'moonwell_outside_',
|
||||
);
|
||||
addTearDown(() => outside.delete(recursive: true));
|
||||
final outsideFile = File(
|
||||
'${outside.path}${Platform.pathSeparator}keep.dll',
|
||||
);
|
||||
await outsideFile.writeAsString('keep');
|
||||
final link = Link(
|
||||
service.resolveClientPath(rootDirectory.path, 'Data/external'),
|
||||
);
|
||||
await link.create(outside.path);
|
||||
final manifest = await _manifestForPaths(service, rootDirectory, [
|
||||
'Data/common.MPQ',
|
||||
]);
|
||||
|
||||
// expect(paths, ['Data/common.MPQ', 'Wow.exe']);
|
||||
// expect(
|
||||
// await Directory(
|
||||
// '${rootDirectory.path}${Platform.pathSeparator}${Config.launcherMetadataDirectoryName}',
|
||||
// ).exists(),
|
||||
// isTrue,
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
final snapshot = await service.scanInstallation(
|
||||
rootDirectory.path,
|
||||
manifest: manifest,
|
||||
);
|
||||
expect(snapshot.stalePaths, ['Data/external']);
|
||||
await service.deleteRelativeFile(rootDirectory.path, 'Data/external');
|
||||
expect(await link.exists(), isFalse);
|
||||
expect(await outsideFile.readAsString(), 'keep');
|
||||
});
|
||||
|
||||
test('retains a directory link needed by manifest files', () async {
|
||||
final target = Directory(
|
||||
service.resolveClientPath(rootDirectory.path, 'shared-data'),
|
||||
);
|
||||
await target.create();
|
||||
await File(
|
||||
'${target.path}${Platform.pathSeparator}required.MPQ',
|
||||
).writeAsString('required');
|
||||
final link = Link(
|
||||
service.resolveClientPath(rootDirectory.path, 'Data/linked'),
|
||||
);
|
||||
await link.create(target.path);
|
||||
final manifest = await _manifestForPaths(service, rootDirectory, [
|
||||
'Data/common.MPQ',
|
||||
'Data/linked/required.MPQ',
|
||||
]);
|
||||
final snapshot = await service.scanInstallation(
|
||||
rootDirectory.path,
|
||||
manifest: manifest,
|
||||
);
|
||||
expect(snapshot.stalePaths, isEmpty);
|
||||
expect(snapshot.buildHash, manifest.buildHash);
|
||||
expect(await link.exists(), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'rejects deleting through a directory link outside installation',
|
||||
() async {
|
||||
final outside = await Directory.systemTemp.createTemp(
|
||||
'moonwell_outside_',
|
||||
);
|
||||
addTearDown(() => outside.delete(recursive: true));
|
||||
final outsideFile = File(
|
||||
'${outside.path}${Platform.pathSeparator}keep.dll',
|
||||
);
|
||||
await outsideFile.writeAsString('keep');
|
||||
final link = Link(
|
||||
service.resolveClientPath(rootDirectory.path, 'Data/external'),
|
||||
);
|
||||
await link.create(outside.path);
|
||||
await expectLater(
|
||||
service.deleteRelativeFile(
|
||||
rootDirectory.path,
|
||||
'Data/external/keep.dll',
|
||||
),
|
||||
throwsA(isA<LauncherSyncException>()),
|
||||
);
|
||||
expect(await outsideFile.readAsString(), 'keep');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'preserves Windows manifest files despite path case differences',
|
||||
() async {
|
||||
final file = File(
|
||||
service.resolveClientPath(
|
||||
rootDirectory.path,
|
||||
'Data/ruRU/required.DLL',
|
||||
),
|
||||
);
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsString('required');
|
||||
final manifest = await _manifestForPaths(service, rootDirectory, [
|
||||
'data/ruru/required.dll',
|
||||
]);
|
||||
final snapshot = await service.scanInstallation(
|
||||
rootDirectory.path,
|
||||
manifest: manifest,
|
||||
);
|
||||
expect(snapshot.stalePaths, ['Data/common.MPQ']);
|
||||
expect(snapshot.buildHash, manifest.buildHash);
|
||||
},
|
||||
skip: !Platform.isWindows,
|
||||
);
|
||||
|
||||
test('preserves WoW.exe when explicitly present in manifest', () async {
|
||||
await File(
|
||||
service.resolveClientPath(rootDirectory.path, 'WoW.exe'),
|
||||
).writeAsString('legacy');
|
||||
final manifest = await _manifestForPaths(service, rootDirectory, [
|
||||
'WoW.exe',
|
||||
'Data/common.MPQ',
|
||||
]);
|
||||
final snapshot = await service.scanInstallation(
|
||||
rootDirectory.path,
|
||||
manifest: manifest,
|
||||
);
|
||||
expect(snapshot.stalePaths, isEmpty);
|
||||
expect(snapshot.buildHash, manifest.buildHash);
|
||||
});
|
||||
|
||||
test('rejects invalid manifest paths before scanning', () async {
|
||||
for (final path in [
|
||||
'../outside.dll',
|
||||
'/outside.dll',
|
||||
r'C:\outside.dll',
|
||||
'C:outside.dll',
|
||||
'Data/file:stream',
|
||||
]) {
|
||||
final manifest = ClientManifest.fromJson({
|
||||
'files': [
|
||||
{'path': path, 'size': 0, 'sha256': ''},
|
||||
],
|
||||
});
|
||||
await expectLater(
|
||||
service.scanInstallation(rootDirectory.path, manifest: manifest),
|
||||
throwsA(isA<LauncherSyncException>()),
|
||||
);
|
||||
}
|
||||
expect(
|
||||
await File(service.getHashCachePath(rootDirectory.path)).exists(),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'never recursively deletes a stale directory with new files',
|
||||
() async {
|
||||
final file = File(
|
||||
service.resolveClientPath(rootDirectory.path, 'Data/old/new.MPQ'),
|
||||
);
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsString('new');
|
||||
await expectLater(
|
||||
service.deleteRelativeFile(rootDirectory.path, 'Data/old'),
|
||||
throwsA(isA<FileSystemException>()),
|
||||
);
|
||||
expect(await file.readAsString(), 'new');
|
||||
},
|
||||
);
|
||||
|
||||
test('clearCache recreates an empty cache directory', () async {
|
||||
await service.clearCache(rootDirectory.path);
|
||||
@@ -167,18 +420,256 @@ void main() {
|
||||
);
|
||||
|
||||
expect(recordingService.arguments, isEmpty);
|
||||
expect(
|
||||
recordingService.executablePath,
|
||||
service.getExecutablePath(rootDirectory.path),
|
||||
);
|
||||
expect(recordingService.workingDirectory, rootDirectory.path);
|
||||
expect(recordingService.environment, <String, String>{
|
||||
'MOONWELL_LAUNCH_ACCOUNT': 'PLAYERONE',
|
||||
'MOONWELL_LAUNCH_TICKET': 'A1B2C3D4E5F6G7H8',
|
||||
'WOW_USER': 'PLAYERONE',
|
||||
'WOW_PASS': 'A1B2C3D4E5F6G7H8',
|
||||
'WOW_HOST': '127.0.0.1:3724',
|
||||
'WOW_DATA': '${rootDirectory.path}${Platform.pathSeparator}Data',
|
||||
'WOW_LOCALE': 'auto',
|
||||
'BENILLA_HOME':
|
||||
'${rootDirectory.path}${Platform.pathSeparator}benilla-config',
|
||||
});
|
||||
expect(
|
||||
await File(
|
||||
'${rootDirectory.path}${Platform.pathSeparator}realmlist.wtf',
|
||||
).readAsString(),
|
||||
'set realmlist 127.0.0.1:3724\n',
|
||||
);
|
||||
expect(
|
||||
await File(
|
||||
'${rootDirectory.path}${Platform.pathSeparator}Cache${Platform.pathSeparator}cache.bin',
|
||||
).readAsString(),
|
||||
'cache',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'macOS starts the app launcher and keeps state outside the bundle',
|
||||
() async {
|
||||
final recordingService = _RecordingGameInstallationService(
|
||||
platform: 'macos',
|
||||
);
|
||||
final executable = File(
|
||||
recordingService.getExecutablePath(rootDirectory.path),
|
||||
);
|
||||
await executable.parent.create(recursive: true);
|
||||
await executable.writeAsString('#!/bin/sh\n');
|
||||
await recordingService.launchGame(
|
||||
rootDirectory.path,
|
||||
authorization: _validTicket(),
|
||||
);
|
||||
expect(recordingService.executablePath, executable.path);
|
||||
expect(
|
||||
recordingService.environment?['BENILLA_HOME'],
|
||||
'${rootDirectory.path}${Platform.pathSeparator}benilla-config',
|
||||
);
|
||||
expect(recordingService.arguments, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('uses WoW/Data when the install has a nested Data directory', () async {
|
||||
await Directory(
|
||||
'${rootDirectory.path}${Platform.pathSeparator}Data',
|
||||
).rename('${rootDirectory.path}${Platform.pathSeparator}WoW');
|
||||
final nestedData = Directory(
|
||||
'${rootDirectory.path}${Platform.pathSeparator}WoW${Platform.pathSeparator}Data',
|
||||
);
|
||||
await nestedData.create();
|
||||
final recordingService = _RecordingGameInstallationService();
|
||||
await recordingService.launchGame(
|
||||
rootDirectory.path,
|
||||
authorization: _validTicket(),
|
||||
);
|
||||
expect(recordingService.environment?['WOW_DATA'], nestedData.path);
|
||||
});
|
||||
|
||||
test('verifies and retains Russian archives before launching', () async {
|
||||
final paths = [
|
||||
'MoonWell.exe',
|
||||
'Data/common.MPQ',
|
||||
'Data/ruRU/patch-1.MPQ',
|
||||
'Data/ruRU/patch-2.MPQ',
|
||||
'Data/ruRU/patch-3.MPQ',
|
||||
];
|
||||
for (final path in paths.skip(2)) {
|
||||
final file = File(service.resolveClientPath(rootDirectory.path, path));
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsString('localized archive: $path');
|
||||
}
|
||||
final manifest = await _manifestForPaths(service, rootDirectory, paths);
|
||||
final snapshot = await service.scanInstallation(
|
||||
rootDirectory.path,
|
||||
manifest: manifest,
|
||||
);
|
||||
expect(snapshot.buildHash, manifest.buildHash);
|
||||
expect(snapshot.stalePaths, isEmpty);
|
||||
final recordingService = _RecordingGameInstallationService();
|
||||
await recordingService.launchGame(
|
||||
rootDirectory.path,
|
||||
authorization: _validTicket(),
|
||||
);
|
||||
expect(recordingService.environment?['WOW_LOCALE'], 'auto');
|
||||
expect(
|
||||
recordingService.environment?['WOW_DATA'],
|
||||
'${rootDirectory.path}${Platform.pathSeparator}Data',
|
||||
);
|
||||
for (final path in paths.skip(2)) {
|
||||
expect(
|
||||
await File(
|
||||
service.resolveClientPath(rootDirectory.path, path),
|
||||
).readAsString(),
|
||||
'localized archive: $path',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('configured auth address overrides the install realmlist', () async {
|
||||
final configured = _RecordingGameInstallationService(
|
||||
authAddress: 'login.example:3725',
|
||||
);
|
||||
expect(
|
||||
await configured.resolveAuthAddress(rootDirectory.path),
|
||||
'login.example:3725',
|
||||
);
|
||||
});
|
||||
|
||||
test('reads a quoted realmlist with CRLF without modifying it', () async {
|
||||
final file = File(
|
||||
'${rootDirectory.path}${Platform.pathSeparator}realmlist.wtf',
|
||||
);
|
||||
await file.writeAsString('SET REALMLIST "login.example:3724"\r\n');
|
||||
expect(
|
||||
await service.resolveAuthAddress(rootDirectory.path),
|
||||
'login.example:3724',
|
||||
);
|
||||
expect(
|
||||
await file.readAsString(),
|
||||
'SET REALMLIST "login.example:3724"\r\n',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects missing or malformed auth endpoints', () async {
|
||||
final file = File(
|
||||
'${rootDirectory.path}${Platform.pathSeparator}realmlist.wtf',
|
||||
);
|
||||
await file.delete();
|
||||
await expectLater(
|
||||
service.resolveAuthAddress(rootDirectory.path),
|
||||
throwsA(isA<LauncherSyncException>()),
|
||||
);
|
||||
for (final address in [
|
||||
'https://login.example',
|
||||
'login.example:0',
|
||||
'login.example:65536',
|
||||
]) {
|
||||
final configured = _RecordingGameInstallationService(
|
||||
authAddress: address,
|
||||
);
|
||||
await expectLater(
|
||||
configured.resolveAuthAddress(rootDirectory.path),
|
||||
throwsA(isA<LauncherSyncException>()),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not start a process with an expired ticket', () async {
|
||||
final recordingService = _RecordingGameInstallationService();
|
||||
await expectLater(
|
||||
recordingService.launchGame(
|
||||
rootDirectory.path,
|
||||
authorization: LauncherGameTicket(
|
||||
account: 'PLAYERONE',
|
||||
ticket: 'A1B2C3D4E5F6G7H8',
|
||||
expiresAt: DateTime.utc(2020),
|
||||
),
|
||||
),
|
||||
throwsA(isA<LauncherSyncException>()),
|
||||
);
|
||||
expect(recordingService.environment, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'does not launch a missing client or an installation without data',
|
||||
() async {
|
||||
final recordingService = _RecordingGameInstallationService();
|
||||
final executable = File(
|
||||
recordingService.getExecutablePath(rootDirectory.path),
|
||||
);
|
||||
await executable.delete();
|
||||
await expectLater(
|
||||
recordingService.launchGame(
|
||||
rootDirectory.path,
|
||||
authorization: _validTicket(),
|
||||
),
|
||||
throwsA(isA<LauncherSyncException>()),
|
||||
);
|
||||
await executable.writeAsString('moonwell');
|
||||
await Directory(
|
||||
'${rootDirectory.path}${Platform.pathSeparator}Data',
|
||||
).delete(recursive: true);
|
||||
await expectLater(
|
||||
recordingService.launchGame(
|
||||
rootDirectory.path,
|
||||
authorization: _validTicket(),
|
||||
),
|
||||
throwsA(isA<LauncherSyncException>()),
|
||||
);
|
||||
expect(recordingService.environment, isNull);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<ClientManifest> _manifestForPaths(
|
||||
GameInstallationService service,
|
||||
Directory rootDirectory,
|
||||
List<String> paths,
|
||||
) async {
|
||||
final files = <Map<String, Object>>[];
|
||||
for (final path in paths) {
|
||||
final bytes = await File(
|
||||
service.resolveClientPath(rootDirectory.path, path),
|
||||
).readAsBytes();
|
||||
files.add({
|
||||
'path': path,
|
||||
'size': bytes.length,
|
||||
'sha256': sha256.convert(bytes).toString(),
|
||||
});
|
||||
}
|
||||
return ClientManifest.fromJson({'files': files});
|
||||
}
|
||||
|
||||
LauncherGameTicket _validTicket() => LauncherGameTicket(
|
||||
account: 'PLAYERONE',
|
||||
ticket: 'A1B2C3D4E5F6G7H8',
|
||||
expiresAt: DateTime.now().toUtc().add(const Duration(seconds: 60)),
|
||||
);
|
||||
|
||||
final class _RecordingGameInstallationService extends GameInstallationService {
|
||||
_RecordingGameInstallationService({
|
||||
this.platform = 'windows',
|
||||
this.authAddress = '',
|
||||
});
|
||||
|
||||
final String platform;
|
||||
final String authAddress;
|
||||
|
||||
@override
|
||||
String get operatingSystem => platform;
|
||||
|
||||
@override
|
||||
String get configuredAuthAddress => authAddress;
|
||||
|
||||
Map<String, String>? environment;
|
||||
List<String>? arguments;
|
||||
String? executablePath;
|
||||
String? workingDirectory;
|
||||
|
||||
@override
|
||||
Future<GameProcessHandle> startGameProcess({
|
||||
@@ -189,6 +680,8 @@ final class _RecordingGameInstallationService extends GameInstallationService {
|
||||
}) async {
|
||||
this.arguments = List<String>.from(arguments);
|
||||
this.environment = Map<String, String>.from(environment);
|
||||
this.executablePath = executablePath;
|
||||
this.workingDirectory = workingDirectory;
|
||||
return GameProcessHandle(pid: 42, exitCode: Future<int>.value(0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
|
||||
|
||||
void main() {
|
||||
test('requests a Vanilla game ticket with bearer authentication', () async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
addTearDown(() => server.close(force: true));
|
||||
final client = _LocalLauncherApiClient(server.port);
|
||||
final ticketFuture = client.issueGameTicket('test-launcher-token');
|
||||
final request = await server.first;
|
||||
expect(request.method, 'POST');
|
||||
expect(request.uri.path, '/api/launcher/game-ticket');
|
||||
expect(
|
||||
request.headers.value(HttpHeaders.authorizationHeader),
|
||||
'Bearer test-launcher-token',
|
||||
);
|
||||
expect(jsonDecode(await utf8.decoder.bind(request).join()), {
|
||||
'client_build': 5875,
|
||||
});
|
||||
request.response.headers.contentType = ContentType.json;
|
||||
request.response.write(
|
||||
jsonEncode({
|
||||
'account': 'PLAYERONE',
|
||||
'ticket': 'A1B2C3D4E5F6G7H8',
|
||||
'expires_at': DateTime.now()
|
||||
.toUtc()
|
||||
.add(const Duration(seconds: 60))
|
||||
.toIso8601String(),
|
||||
}),
|
||||
);
|
||||
await request.response.close();
|
||||
final ticket = await ticketFuture;
|
||||
expect(ticket.account, 'PLAYERONE');
|
||||
expect(ticket.ticket, 'A1B2C3D4E5F6G7H8');
|
||||
});
|
||||
}
|
||||
|
||||
class _LocalLauncherApiClient extends LauncherApiClient {
|
||||
_LocalLauncherApiClient(this.port);
|
||||
|
||||
final int port;
|
||||
|
||||
@override
|
||||
String get apiBaseUrl => 'http://127.0.0.1:$port';
|
||||
}
|
||||
@@ -37,6 +37,24 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects accounts incompatible with the web 1.12.1 contract', () {
|
||||
for (final account in [
|
||||
'AB',
|
||||
'ABCDEFGHIJKLMNOPQ',
|
||||
'PLAYER_NAME',
|
||||
'playerone',
|
||||
]) {
|
||||
expect(
|
||||
() => LauncherGameTicket.fromJson(<String, Object?>{
|
||||
'account': account,
|
||||
'ticket': 'A1B2C3D4E5F6G7H8',
|
||||
'expires_at': '2026-08-16T12:01:00Z',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('requires enough validity to start the game', () {
|
||||
final ticket = LauncherGameTicket(
|
||||
account: 'PLAYERONE',
|
||||
|
||||
@@ -52,6 +52,7 @@ param(
|
||||
[string]$DartCommand = 'dart',
|
||||
[string]$InnoSetupCommand,
|
||||
[string]$OpenSslCommand,
|
||||
[string]$VcRuntimePath,
|
||||
[switch]$SkipChecks,
|
||||
[switch]$DryRun,
|
||||
[switch]$Force
|
||||
@@ -93,9 +94,19 @@ function Invoke-RequiredCommand {
|
||||
[Parameter(Mandatory = $true)][string[]]$Arguments
|
||||
)
|
||||
|
||||
& $Command @Arguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Command failed with exit code ${LASTEXITCODE}: $Command"
|
||||
# Windows PowerShell represents native stderr as error records even on exit 0.
|
||||
# OpenSSL writes its normal key-reading notices there; the exit code is the verdict.
|
||||
$previousPreference = $ErrorActionPreference
|
||||
try {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
& $Command @Arguments
|
||||
$commandExitCode = $LASTEXITCODE
|
||||
}
|
||||
finally {
|
||||
$ErrorActionPreference = $previousPreference
|
||||
}
|
||||
if ($commandExitCode -ne 0) {
|
||||
throw "Command failed with exit code ${commandExitCode}: $Command"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,6 +530,27 @@ try {
|
||||
"--dart-define=MOONWELL_APPCAST_URL=$AppcastUrl"
|
||||
)
|
||||
|
||||
Write-Step 'Bundling the x64 Visual C++ runtime'
|
||||
if ([string]::IsNullOrWhiteSpace($VcRuntimePath)) {
|
||||
$vswherePath = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
|
||||
$vsInstall = (& $vswherePath -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath).Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or -not $vsInstall) {
|
||||
throw 'Visual Studio C++ runtime was not found; pass -VcRuntimePath.'
|
||||
}
|
||||
$redistVersion = Get-ChildItem -LiteralPath (Join-Path $vsInstall 'VC\Redist\MSVC') -Directory |
|
||||
Where-Object { $_.Name -match '^\d+\.\d+\.\d+$' } |
|
||||
Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1
|
||||
$VcRuntimePath = Join-Path $redistVersion.FullName 'x64\Microsoft.VC143.CRT'
|
||||
}
|
||||
foreach ($requiredDll in @('msvcp140.dll', 'vcruntime140.dll', 'vcruntime140_1.dll')) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $VcRuntimePath $requiredDll) -PathType Leaf)) {
|
||||
throw "The x64 Visual C++ redistributable $requiredDll was not found."
|
||||
}
|
||||
}
|
||||
Get-ChildItem -LiteralPath $VcRuntimePath -Filter '*.dll' -File | ForEach-Object {
|
||||
Copy-Item -LiteralPath $_.FullName -Destination 'build\windows\x64\runner\Release'
|
||||
}
|
||||
|
||||
Write-Step 'Building the Inno Setup installer'
|
||||
Invoke-RequiredCommand -Command $innoSetup -Arguments @(
|
||||
"/DMyAppVersion=$version",
|
||||
@@ -537,12 +569,9 @@ try {
|
||||
$previousPath = $env:PATH
|
||||
try {
|
||||
$env:PATH = "$gitOpenSslDirectory;$env:PATH"
|
||||
$signOutput = & $dart run auto_updater:sign_update `
|
||||
$installerPath `
|
||||
$privateKey 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Could not sign the installer: $signOutput"
|
||||
}
|
||||
$signOutput = Invoke-RequiredCommand -Command $dart -Arguments @(
|
||||
'run', 'auto_updater:sign_update', $installerPath, $privateKey
|
||||
) 2>&1
|
||||
}
|
||||
finally {
|
||||
$env:PATH = $previousPath
|
||||
|
||||
Reference in New Issue
Block a user