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",