diff --git a/AGENTS.md b/AGENTS.md
index 0bcaff0..738fdde 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
@@ -93,6 +98,12 @@ or local-only API URLs.
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
diff --git a/README.md b/README.md
index e1b20d5..8347343 100644
--- a/README.md
+++ b/README.md
@@ -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,7 @@ 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
## Stack
@@ -60,6 +61,11 @@ 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.
+
Example run command:
```powershell
@@ -141,8 +147,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.
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 8d3a3de..16eb996 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -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:
diff --git a/appcast.xml b/appcast.xml
index a7b42e1..6066c61 100644
--- a/appcast.xml
+++ b/appcast.xml
@@ -5,10 +5,10 @@
MoonWell Launcher updates
ru
-
- MoonWell Launcher 1.0.2
- Исправлена работа с аддонами.
- Fri, 07 Aug 2026 12:08:41 +04:00
-
+ MoonWell Launcher 1.0.5
+ Запуск Rust-клиента MoonWell 1.12.1, вход через игровой билет, обновления клиента и необходимые библиотеки Visual C++ в установщике.
+ Sun, 04 Oct 2026 13:02:31 +04:00
+
\ No newline at end of file
diff --git a/docs/launcher_web_api_spec.md b/docs/launcher_web_api_spec.md
index 91c786c..43b21d9 100644
--- a/docs/launcher_web_api_spec.md
+++ b/docs/launcher_web_api_spec.md
@@ -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.
@@ -142,6 +144,7 @@ The launcher excludes these top-level directories from verification:
- `Logs`
- `Screenshots`
- `WTF`
+- `benilla-config`
Files under these directories:
@@ -244,20 +247,32 @@ Pause is implemented as cooperative cancellation:
When the user presses `Play`:
-1. launcher removes every `SET accountName ...` line from
- `/WTF/Config.wtf` when that file exists
-2. launcher resolves `/Wow.exe`
-3. launcher clears `/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 `/MoonWell.exe` on Windows,
+ `/MoonWell.app/Contents/MacOS/launch` on macOS, or
+ `/MoonWell` on Linux
+3. launcher reads the auth address from `MOONWELL_AUTH_ADDRESS` when configured,
+ otherwise from `set realmlist ` 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=`
- - `MOONWELL_LAUNCH_TICKET=`
+ - `WOW_USER=`
+ - `WOW_PASS=`
+ - `WOW_HOST=`
+ - `WOW_DATA=`
+ - `BENILLA_HOME=/benilla-config`
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.
## Error Behavior
@@ -271,7 +286,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
diff --git a/docs/production_deploy.md b/docs/production_deploy.md
index 0a6a14d..0c97ff2 100644
--- a/docs/production_deploy.md
+++ b/docs/production_deploy.md
@@ -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.
diff --git a/lib/app/design_system/mw_shells.dart b/lib/app/design_system/mw_shells.dart
index 882267b..fe81d9b 100644
--- a/lib/app/design_system/mw_shells.dart
+++ b/lib/app/design_system/mw_shells.dart
@@ -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,
diff --git a/lib/app/home_screen/bloc/home_screen_bloc.dart b/lib/app/home_screen/bloc/home_screen_bloc.dart
index f71c8f7..ec7e3d1 100644
--- a/lib/app/home_screen/bloc/home_screen_bloc.dart
+++ b/lib/app/home_screen/bloc/home_screen_bloc.dart
@@ -351,8 +351,6 @@ class HomeScreenBloc extends Bloc {
_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 {
model: state.model.copyWith(
phase: HomeScreenPhase.readyToPlay,
isGameRunning: true,
- statusText: 'Игра запущена. Папка Cache очищена перед стартом.',
+ statusText: 'MoonWell запущен.',
errorMessage: null,
),
),
@@ -418,7 +416,7 @@ class HomeScreenBloc extends Bloc {
isGameRunning: false,
statusText: hasClientExecutable
? 'Игра закрыта. Клиент готов к запуску.'
- : 'Игра закрыта, но Wow.exe больше не найден.',
+ : 'Игра закрыта, но клиент MoonWell больше не найден.',
errorMessage: null,
),
),
diff --git a/lib/config.dart b/lib/config.dart
index 7431190..567ee30 100644
--- a/lib/config.dart
+++ b/lib/config.dart
@@ -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',
};
}
diff --git a/lib/features/launcher/data/game_installation_service.dart b/lib/features/launcher/data/game_installation_service.dart
index 1e44094..8b3119d 100644
--- a/lib/features/launcher/data/game_installation_service.dart
+++ b/lib/features/launcher/data/game_installation_service.dart
@@ -33,6 +33,10 @@ final class GameProcessHandle {
@lazySingleton
class GameInstallationService {
+ String get operatingSystem => Platform.operatingSystem;
+
+ String get configuredAuthAddress => Config.gameAuthAddress;
+
Future scanInstallation(
String installationDir, {
required ClientManifest manifest,
@@ -249,18 +253,39 @@ 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 [],
- workingDirectory: installationDir,
+ workingDirectory: rootPath,
environment: {
- 'MOONWELL_LAUNCH_ACCOUNT': authorization.account,
- 'MOONWELL_LAUNCH_TICKET': authorization.ticket,
+ 'WOW_USER': authorization.account,
+ 'WOW_PASS': authorization.ticket,
+ 'WOW_HOST': authAddress,
+ 'WOW_DATA': dataPath,
+ 'BENILLA_HOME': p.join(rootPath, 'benilla-config'),
},
);
}
@@ -308,7 +333,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 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) {
diff --git a/lib/features/launcher/data/launcher_api_client.dart b/lib/features/launcher/data/launcher_api_client.dart
index 7e22269..426b41e 100644
--- a/lib/features/launcher/data/launcher_api_client.dart
+++ b/lib/features/launcher/data/launcher_api_client.dart
@@ -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 {'client_build': 12340},
+ data: const {'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.',
diff --git a/lib/features/launcher/domain/entities/launcher_game_ticket.dart b/lib/features/launcher/domain/entities/launcher_game_ticket.dart
index d1b55af..aba220c 100644
--- a/lib/features/launcher/domain/entities/launcher_game_ticket.dart
+++ b/lib/features/launcher/domain/entities/launcher_game_ticket.dart
@@ -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;
diff --git a/pubspec.lock b/pubspec.lock
index 79a58e3..2aeb375 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -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"
diff --git a/pubspec.yaml b/pubspec.yaml
index 034e486..596e317 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -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.5+8
environment:
sdk: ">=3.10.0"
diff --git a/test/app/home_screen/bloc/home_screen_bloc_test.dart b/test/app/home_screen/bloc/home_screen_bloc_test.dart
index b1faace..0a3dcff 100644
--- a/test/app/home_screen/bloc/home_screen_bloc_test.dart
+++ b/test/app/home_screen/bloc/home_screen_bloc_test.dart
@@ -173,13 +173,8 @@ void main() {
await pumpEventQueue(times: 20);
expect(gameService.launchCount, 1);
- expect(gameService.savedAccountNameRemovalCount, 1);
- expect(launchOperations, [
- 'removeSavedAccountName',
- 'clearCache',
- 'issueGameTicket',
- 'launchGame',
- ]);
+ expect(gameService.savedAccountNameRemovalCount, 0);
+ expect(launchOperations, ['issueGameTicket', 'launchGame']);
expect(gameService.launchedWith?.account, 'PLAYERONE');
expect(gameService.launchedWith?.ticket, 'A1B2C3D4E5F6G7H8');
expect(bloc.state.model.isGameRunning, isTrue);
diff --git a/test/features/launcher/application/client_sync_use_case_test.dart b/test/features/launcher/application/client_sync_use_case_test.dart
index 786d7d1..2c1fca5 100644
--- a/test/features/launcher/application/client_sync_use_case_test.dart
+++ b/test/features/launcher/application/client_sync_use_case_test.dart
@@ -12,6 +12,7 @@ import 'package:moonwell_launcher/features/launcher/domain/entities/client_manif
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
// import 'package:moonwell_launcher/features/launcher/domain/entities/client_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';
void main() {
diff --git a/test/features/launcher/data/game_installation_service_test.dart b/test/features/launcher/data/game_installation_service_test.dart
index 2bf2ce0..d9a7ae2 100644
--- a/test/features/launcher/data/game_installation_service_test.dart
+++ b/test/features/launcher/data/game_installation_service_test.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);
@@ -167,18 +170,196 @@ void main() {
);
expect(recordingService.arguments, isEmpty);
+ expect(
+ recordingService.executablePath,
+ service.getExecutablePath(rootDirectory.path),
+ );
+ expect(recordingService.workingDirectory, rootDirectory.path);
expect(recordingService.environment, {
- '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',
+ '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('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()),
+ );
+ 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()),
+ );
+ }
+ });
+
+ 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()),
+ );
+ 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()),
+ );
+ await executable.writeAsString('moonwell');
+ await Directory(
+ '${rootDirectory.path}${Platform.pathSeparator}Data',
+ ).delete(recursive: true);
+ await expectLater(
+ recordingService.launchGame(
+ rootDirectory.path,
+ authorization: _validTicket(),
+ ),
+ throwsA(isA()),
+ );
+ expect(recordingService.environment, isNull);
},
);
});
}
+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? environment;
List? arguments;
+ String? executablePath;
+ String? workingDirectory;
@override
Future startGameProcess({
@@ -189,6 +370,8 @@ final class _RecordingGameInstallationService extends GameInstallationService {
}) async {
this.arguments = List.from(arguments);
this.environment = Map.from(environment);
+ this.executablePath = executablePath;
+ this.workingDirectory = workingDirectory;
return GameProcessHandle(pid: 42, exitCode: Future.value(0));
}
}
diff --git a/test/features/launcher/data/launcher_api_client_test.dart b/test/features/launcher/data/launcher_api_client_test.dart
new file mode 100644
index 0000000..028968d
--- /dev/null
+++ b/test/features/launcher/data/launcher_api_client_test.dart
@@ -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';
+}
diff --git a/test/features/launcher/domain/entities/launcher_game_ticket_test.dart b/test/features/launcher/domain/entities/launcher_game_ticket_test.dart
index 591e406..3750293 100644
--- a/test/features/launcher/domain/entities/launcher_game_ticket_test.dart
+++ b/test/features/launcher/domain/entities/launcher_game_ticket_test.dart
@@ -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({
+ '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',
diff --git a/tool/deploy_launcher.ps1 b/tool/deploy_launcher.ps1
index cbee24d..4f7bcf9 100644
--- a/tool/deploy_launcher.ps1
+++ b/tool/deploy_launcher.ps1
@@ -52,6 +52,7 @@ param(
[string]$DartCommand = 'dart',
[string]$InnoSetupCommand,
[string]$OpenSslCommand,
+ [string]$VcRuntimePath,
[switch]$SkipChecks,
[switch]$DryRun,
[switch]$Force
@@ -519,6 +520,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",