5 Commits

Author SHA1 Message Date
sindoring 7799b33239 Clean obsolete Extentions during client synchronization
Include the prepared cleanup of both client roots while preserving manifest-listed files and unrelated user state. Verified on Windows through FVM: full formatting and analysis, all 76 tests, production dry run, x64 build and DSA signature verification.
2026-10-10 16:36:51 +04:00
sindoring c802839688 Judge deployment commands by exit status on Windows PowerShell
OpenSSL prints normal DSA key notices to stderr. Windows PowerShell treated
those notices as terminating errors under Stop and aborted before signing.
Keep native calls under Continue, then reject nonzero exit codes explicitly.
Use the same invocation helper for installer signing.

Verified on Windows: a native exit 0 with stderr succeeds, exit 7 is rejected,
and the error preference is restored. The full production dry run passed its
format/analyze checks, all 76 tests, the Windows build, Inno Setup packaging,
DSA key matching and installer-signature verification for 1.0.7+10.
2026-10-10 16:15:22 +04:00
sindoring 2fb6774ec6 Select the installed language pack when launching MoonWell
Pass WOW_LOCALE=auto alongside the selected Data directory so inherited locale
settings cannot override an installed ruRU pack. Keep language archives in the
regular manifest download and verification flow. Document the contract and
prepare launcher version 1.0.7+10 for the localization release.

Verified on Windows through FVM: full format check and flutter analyze passed;
all 76 Flutter tests passed, including manifest retention and verification of
Russian archives and the game's child environment. No macOS build was run.
2026-10-10 16:05:55 +04:00
sindoring 18c8e07744 обновленная синхронизация клиента 2026-10-04 21:15:18 +04:00
sindoring d3d19bfaeb обновил лаунчер под новую версию веба 2026-10-04 13:19:12 +04:00
22 changed files with 1347 additions and 322 deletions
+22 -3
View File
@@ -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
+42 -3
View File
@@ -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.
+6
View File
@@ -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
View File
@@ -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.5</title>
<description>Запуск Rust-клиента MoonWell 1.12.1, вход через игровой билет, обновления клиента и необходимые библиотеки Visual C++ в установщике.</description>
<pubDate>Sun, 04 Oct 2026 13:02:31 +04:00</pubDate>
<enclosure url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher/releases/1.0.5/moonwell_launcher_setup.exe" sparkle:version="1.0.5+8" sparkle:shortVersionString="1.0.5" sparkle:os="windows" sparkle:dsaSignature="MDwCHHxkF+i05qKMxqUoWEFXZO5mtSy+0rX9/VvvcsUCHBb8ErwfgwRpS6pZtGwzfBvlze6tUJqkJ2aHrg4=" length="20587331" type="application/octet-stream" />
</item>
</channel>
</rss>
+72 -20
View File
@@ -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
+25 -1
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.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',
+38 -9
View File
@@ -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