wip
This commit is contained in:
Vendored
+3
-2
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"dart.flutterSdkPath": ".fvm/versions/stable"
|
"dart.flutterSdkPath": ".fvm/versions/stable",
|
||||||
}
|
"cmake.sourceDirectory": "C:/Users/sindo/moonwell_launcher/windows"
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ The launcher is responsible for:
|
|||||||
- downloading and synchronizing client files
|
- downloading and synchronizing client files
|
||||||
- displaying launcher news
|
- displaying launcher news
|
||||||
- displaying the live status of the first configured realm
|
- displaying the live status of the first configured realm
|
||||||
- launching the game client
|
- requesting a single-use game ticket and launching the game client without
|
||||||
|
putting credentials in command-line arguments
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -140,7 +141,8 @@ The launcher sync flow is manifest-based:
|
|||||||
6. remove stale files outside ignored directories
|
6. remove stale files outside ignored directories
|
||||||
7. download missing or changed files to temporary part files
|
7. download missing or changed files to temporary part files
|
||||||
8. verify size and SHA-256 before replacing client files
|
8. verify size and SHA-256 before replacing client files
|
||||||
9. launch `Wow.exe` from the selected client directory
|
9. request a short-lived game ticket and launch `Wow.exe` from the selected
|
||||||
|
client directory with authorization in its child environment
|
||||||
|
|
||||||
See `docs/launcher_web_api_spec.md` for the API and sync contract.
|
See `docs/launcher_web_api_spec.md` for the API and sync contract.
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ The launcher uses these endpoints from `openapi.json`:
|
|||||||
5. `GET /api/launcher/news`
|
5. `GET /api/launcher/news`
|
||||||
6. `GET /api/launcher/realms`
|
6. `GET /api/launcher/realms`
|
||||||
7. `GET /api/launcher/account`
|
7. `GET /api/launcher/account`
|
||||||
|
8. `POST /api/launcher/game-ticket`
|
||||||
|
|
||||||
Registration sequence:
|
Registration sequence:
|
||||||
|
|
||||||
@@ -106,6 +107,19 @@ Account sequence:
|
|||||||
3. The response `balance` field is intentionally ignored and never displayed.
|
3. The response `balance` field is intentionally ignored and never displayed.
|
||||||
4. Account metadata failures do not block patching or launching the game.
|
4. Account metadata failures do not block patching or launching the game.
|
||||||
|
|
||||||
|
Game authorization sequence:
|
||||||
|
|
||||||
|
1. Immediately before starting the game, the launcher calls
|
||||||
|
`POST /api/launcher/game-ticket` with bearer authentication and
|
||||||
|
`{ "client_build": 12340 }` in the JSON body.
|
||||||
|
2. The API returns `account`, a 16-character `A-Z0-9` single-use `ticket`, and
|
||||||
|
`expires_at`. The ticket must remain valid for at least five more seconds.
|
||||||
|
3. Issuing a ticket atomically revokes any previous unused game ticket for the
|
||||||
|
same account. Production lifetime must not exceed 60 seconds.
|
||||||
|
4. The launcher never persists or logs the ticket.
|
||||||
|
5. The auth server uses the ticket as the temporary SRP password and consumes
|
||||||
|
it atomically after a successful logon proof.
|
||||||
|
|
||||||
## Installation Directory Rules
|
## Installation Directory Rules
|
||||||
|
|
||||||
The user selects a single installation root directory.
|
The user selects a single installation root directory.
|
||||||
@@ -232,11 +246,14 @@ When the user presses `Play`:
|
|||||||
|
|
||||||
1. launcher resolves `<install-root>/Wow.exe`
|
1. launcher resolves `<install-root>/Wow.exe`
|
||||||
2. launcher clears `<install-root>/Cache`
|
2. launcher clears `<install-root>/Cache`
|
||||||
3. launcher starts `Wow.exe` with working directory set to installation root
|
3. launcher requests a single-use game ticket
|
||||||
and retains a process handle
|
4. launcher starts `Wow.exe` with working directory set to installation root,
|
||||||
4. while the process is alive, the launcher disables repeated game launches
|
no authorization command-line arguments, and these child environment values:
|
||||||
|
- `MOONWELL_LAUNCH_ACCOUNT=<account>`
|
||||||
|
- `MOONWELL_LAUNCH_TICKET=<ticket>`
|
||||||
|
5. launcher retains a process handle; while the process is alive, it disables repeated game launches
|
||||||
and client synchronization
|
and client synchronization
|
||||||
5. when the process exits, the launcher returns to the ready-to-play state
|
6. when the process exits, the launcher returns to the ready-to-play state
|
||||||
|
|
||||||
If `Wow.exe` is missing, launch fails with an error.
|
If `Wow.exe` is missing, launch fails with an error.
|
||||||
|
|
||||||
@@ -253,6 +270,7 @@ The launcher surfaces errors for:
|
|||||||
- missing downloaded temp files before verification
|
- missing downloaded temp files before verification
|
||||||
- final verification mismatch after update
|
- final verification mismatch after update
|
||||||
- missing `Wow.exe`
|
- missing `Wow.exe`
|
||||||
|
- invalid, expired, or unavailable game ticket
|
||||||
|
|
||||||
## Tested Invariants
|
## Tested Invariants
|
||||||
|
|
||||||
|
|||||||
@@ -44,5 +44,11 @@ Source: "{#MyBuildDir}\*"; DestDir: "{app}"; Excludes: "moonwell_launcher.exe";
|
|||||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||||
|
|
||||||
|
[Registry]
|
||||||
|
Root: HKCU; Subkey: "Software\Classes\moonwell"; ValueType: string; ValueName: ""; ValueData: "URL:MoonWell Launcher Protocol"; Flags: uninsdeletekey
|
||||||
|
Root: HKCU; Subkey: "Software\Classes\moonwell"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""
|
||||||
|
Root: HKCU; Subkey: "Software\Classes\moonwell\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"
|
||||||
|
Root: HKCU; Subkey: "Software\Classes\moonwell\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""
|
||||||
|
|
||||||
[Run]
|
[Run]
|
||||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
Timer? _realmRefreshTimer;
|
Timer? _realmRefreshTimer;
|
||||||
int? _activeGamePid;
|
int? _activeGamePid;
|
||||||
bool _pauseRequested = false;
|
bool _pauseRequested = false;
|
||||||
|
bool _gameLaunchPending = false;
|
||||||
|
|
||||||
Future<void> _onHomeScreenLoad(
|
Future<void> _onHomeScreenLoad(
|
||||||
HomeScreenLoad event,
|
HomeScreenLoad event,
|
||||||
@@ -337,17 +338,26 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
HomeScreenPlayRequested event,
|
HomeScreenPlayRequested event,
|
||||||
Emitter<HomeScreenState> emit,
|
Emitter<HomeScreenState> emit,
|
||||||
) async {
|
) async {
|
||||||
|
if (_gameLaunchPending || state.model.isGameRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final outputPath = state.model.outputPath;
|
final outputPath = state.model.outputPath;
|
||||||
if (outputPath == null) {
|
if (outputPath == null) {
|
||||||
emit(_buildErrorState('Сначала выберите папку установки.'));
|
emit(_buildErrorState('Сначала выберите папку установки.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_gameLaunchPending = true;
|
||||||
try {
|
try {
|
||||||
final installationDir = outputPath.toFilePath();
|
final installationDir = outputPath.toFilePath();
|
||||||
await _gameInstallationService.clearCache(installationDir);
|
await _gameInstallationService.clearCache(installationDir);
|
||||||
|
final authorization = await _launcherApiClient.issueGameTicket(
|
||||||
|
_session.accessToken,
|
||||||
|
);
|
||||||
final process = await _gameInstallationService.launchGame(
|
final process = await _gameInstallationService.launchGame(
|
||||||
installationDir,
|
installationDir,
|
||||||
|
authorization: authorization,
|
||||||
);
|
);
|
||||||
_activeGamePid = process.pid;
|
_activeGamePid = process.pid;
|
||||||
unawaited(
|
unawaited(
|
||||||
@@ -382,6 +392,8 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
|||||||
errorMessage: _formatError(error),
|
errorMessage: _formatError(error),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
_gameLaunchPending = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_
|
|||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
@@ -219,7 +220,10 @@ class GameInstallationService {
|
|||||||
await cacheDirectory.create(recursive: true);
|
await cacheDirectory.create(recursive: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<GameProcessHandle> launchGame(String installationDir) async {
|
Future<GameProcessHandle> launchGame(
|
||||||
|
String installationDir, {
|
||||||
|
required LauncherGameTicket authorization,
|
||||||
|
}) async {
|
||||||
final executablePath = getExecutablePath(installationDir);
|
final executablePath = getExecutablePath(installationDir);
|
||||||
final executable = File(executablePath);
|
final executable = File(executablePath);
|
||||||
|
|
||||||
@@ -229,10 +233,29 @@ class GameInstallationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return startGameProcess(
|
||||||
|
executablePath: executablePath,
|
||||||
|
arguments: const <String>[],
|
||||||
|
workingDirectory: installationDir,
|
||||||
|
environment: <String, String>{
|
||||||
|
'MOONWELL_LAUNCH_ACCOUNT': authorization.account,
|
||||||
|
'MOONWELL_LAUNCH_TICKET': authorization.ticket,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<GameProcessHandle> startGameProcess({
|
||||||
|
required String executablePath,
|
||||||
|
required List<String> arguments,
|
||||||
|
required String workingDirectory,
|
||||||
|
required Map<String, String> environment,
|
||||||
|
}) async {
|
||||||
final process = await Process.start(
|
final process = await Process.start(
|
||||||
executablePath,
|
executablePath,
|
||||||
const <String>[],
|
arguments,
|
||||||
workingDirectory: installationDir,
|
workingDirectory: workingDirectory,
|
||||||
|
environment: environment,
|
||||||
|
includeParentEnvironment: true,
|
||||||
mode: ProcessStartMode.normal,
|
mode: ProcessStartMode.normal,
|
||||||
);
|
);
|
||||||
await process.stdin.close();
|
await process.stdin.close();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:moonwell_launcher/features/launcher/domain/entities/client_manif
|
|||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_account.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_account.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
|
||||||
@@ -162,6 +163,47 @@ class LauncherApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<LauncherGameTicket> issueGameTicket(String accessToken) async {
|
||||||
|
final ticketUri = _resolveUri('api/launcher/game-ticket');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _dio.postUri(
|
||||||
|
ticketUri,
|
||||||
|
data: const <String, Object?>{'client_build': 12340},
|
||||||
|
options: _authorizedOptions(accessToken),
|
||||||
|
);
|
||||||
|
final ticket = LauncherGameTicket.fromJson(_coerceMap(response.data));
|
||||||
|
if (!ticket.isUsable()) {
|
||||||
|
throw const LauncherApiException(
|
||||||
|
'Launcher API returned an expired game ticket.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ticket;
|
||||||
|
} on DioException catch (error, stackTrace) {
|
||||||
|
await _launcherLogService.error(
|
||||||
|
'Game ticket request failed.',
|
||||||
|
details: <String, Object?>{
|
||||||
|
'endpoint': ticketUri.toString(),
|
||||||
|
'statusCode': error.response?.statusCode,
|
||||||
|
'dioType': error.type.name,
|
||||||
|
},
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
throw LauncherApiException(_extractErrorMessage(error));
|
||||||
|
} on FormatException catch (error, stackTrace) {
|
||||||
|
await _launcherLogService.error(
|
||||||
|
'Launcher API returned an invalid game ticket payload.',
|
||||||
|
details: <String, Object?>{'endpoint': ticketUri.toString()},
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
throw const LauncherApiException(
|
||||||
|
'Launcher API returned an invalid game ticket.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<ClientManifest> fetchManifest(String accessToken) async {
|
Future<ClientManifest> fetchManifest(String accessToken) async {
|
||||||
final manifestUri = _resolveUri('api/launcher/manifest');
|
final manifestUri = _resolveUri('api/launcher/manifest');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
final class LauncherGameTicket {
|
||||||
|
static final RegExp _accountPattern = RegExp(r'^[!-<>-~]{1,17}$');
|
||||||
|
static final RegExp _ticketPattern = RegExp(r'^[A-Z0-9]{16}$');
|
||||||
|
|
||||||
|
final String account;
|
||||||
|
final String ticket;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
|
||||||
|
const LauncherGameTicket({
|
||||||
|
required this.account,
|
||||||
|
required this.ticket,
|
||||||
|
required this.expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LauncherGameTicket.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawAccount = json['account'];
|
||||||
|
final rawTicket = json['ticket'];
|
||||||
|
final rawExpiresAt = json['expires_at'];
|
||||||
|
if (rawAccount is! String ||
|
||||||
|
rawTicket is! String ||
|
||||||
|
rawExpiresAt is! String) {
|
||||||
|
throw const FormatException('Invalid game ticket payload types.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final account = rawAccount;
|
||||||
|
final ticket = rawTicket;
|
||||||
|
final expiresAt = DateTime.tryParse(rawExpiresAt);
|
||||||
|
|
||||||
|
if (!_accountPattern.hasMatch(account)) {
|
||||||
|
throw const FormatException('Invalid game ticket account.');
|
||||||
|
}
|
||||||
|
if (!_ticketPattern.hasMatch(ticket)) {
|
||||||
|
throw const FormatException('Invalid game ticket value.');
|
||||||
|
}
|
||||||
|
if (expiresAt == null) {
|
||||||
|
throw const FormatException('Invalid game ticket expiration.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return LauncherGameTicket(
|
||||||
|
account: account,
|
||||||
|
ticket: ticket,
|
||||||
|
expiresAt: expiresAt.toUtc(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isUsable({
|
||||||
|
DateTime? now,
|
||||||
|
Duration minimumValidity = const Duration(seconds: 5),
|
||||||
|
}) {
|
||||||
|
final currentTime = (now ?? DateTime.now()).toUtc();
|
||||||
|
return expiresAt.isAfter(currentTime.add(minimumValidity));
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
-1
@@ -109,6 +109,83 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/launcher/game-ticket": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Launcher Auth"
|
||||||
|
],
|
||||||
|
"summary": "Выпустить одноразовый ticket для входа в игру",
|
||||||
|
"description": "Отзывает предыдущий неиспользованный ticket аккаунта и возвращает временный SRP-пароль. Ticket не сохраняется и действует не более 60 секунд.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"required": [
|
||||||
|
"client_build"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"client_build": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 12340
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Одноразовая игровая авторизация",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"required": [
|
||||||
|
"account",
|
||||||
|
"ticket",
|
||||||
|
"expires_at"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"account": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "PLAYERONE",
|
||||||
|
"maxLength": 17,
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"ticket": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Z0-9]{16}$",
|
||||||
|
"example": "A1B2C3D4E5F6G7H8"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"example": "2026-08-16T12:01:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Launcher-сессия отсутствует или истекла"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Версия клиента не разрешена"
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Слишком много запросов"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"launcherAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/launcher/manifest": {
|
"/api/launcher/manifest": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -702,4 +779,4 @@
|
|||||||
"description": "Launcher Service"
|
"description": "Launcher Service"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dar
|
|||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_account.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_account.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_news_item.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_realm.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
|
||||||
@@ -168,6 +169,8 @@ void main() {
|
|||||||
await pumpEventQueue(times: 20);
|
await pumpEventQueue(times: 20);
|
||||||
|
|
||||||
expect(gameService.launchCount, 1);
|
expect(gameService.launchCount, 1);
|
||||||
|
expect(gameService.launchedWith?.account, 'PLAYERONE');
|
||||||
|
expect(gameService.launchedWith?.ticket, 'A1B2C3D4E5F6G7H8');
|
||||||
expect(bloc.state.model.isGameRunning, isTrue);
|
expect(bloc.state.model.isGameRunning, isTrue);
|
||||||
expect(bloc.state.model.canPlay, isFalse);
|
expect(bloc.state.model.canPlay, isFalse);
|
||||||
|
|
||||||
@@ -234,6 +237,7 @@ class _FakeLauncherApiClient extends LauncherApiClient {
|
|||||||
final List<LauncherRealm> realms;
|
final List<LauncherRealm> realms;
|
||||||
final LauncherAccount account;
|
final LauncherAccount account;
|
||||||
int manifestRequestCount = 0;
|
int manifestRequestCount = 0;
|
||||||
|
int gameTicketRequestCount = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<LauncherNewsItem>> fetchNews(String accessToken) async =>
|
Future<List<LauncherNewsItem>> fetchNews(String accessToken) async =>
|
||||||
@@ -245,6 +249,16 @@ class _FakeLauncherApiClient extends LauncherApiClient {
|
|||||||
@override
|
@override
|
||||||
Future<LauncherAccount> fetchAccount(String accessToken) async => account;
|
Future<LauncherAccount> fetchAccount(String accessToken) async => account;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LauncherGameTicket> issueGameTicket(String accessToken) async {
|
||||||
|
gameTicketRequestCount += 1;
|
||||||
|
return LauncherGameTicket(
|
||||||
|
account: 'PLAYERONE',
|
||||||
|
ticket: 'A1B2C3D4E5F6G7H8',
|
||||||
|
expiresAt: DateTime.now().toUtc().add(const Duration(seconds: 60)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ClientManifest> fetchManifest(String accessToken) async {
|
Future<ClientManifest> fetchManifest(String accessToken) async {
|
||||||
manifestRequestCount += 1;
|
manifestRequestCount += 1;
|
||||||
@@ -272,6 +286,7 @@ class _FakeGameInstallationService extends GameInstallationService {
|
|||||||
class _TrackingGameInstallationService extends _FakeGameInstallationService {
|
class _TrackingGameInstallationService extends _FakeGameInstallationService {
|
||||||
final Completer<int> exitCode = Completer<int>();
|
final Completer<int> exitCode = Completer<int>();
|
||||||
int launchCount = 0;
|
int launchCount = 0;
|
||||||
|
LauncherGameTicket? launchedWith;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> hasClientExecutable(String installationDir) async => true;
|
Future<bool> hasClientExecutable(String installationDir) async => true;
|
||||||
@@ -280,8 +295,12 @@ class _TrackingGameInstallationService extends _FakeGameInstallationService {
|
|||||||
Future<void> clearCache(String installationDir) async {}
|
Future<void> clearCache(String installationDir) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<GameProcessHandle> launchGame(String installationDir) async {
|
Future<GameProcessHandle> launchGame(
|
||||||
|
String installationDir, {
|
||||||
|
required LauncherGameTicket authorization,
|
||||||
|
}) async {
|
||||||
launchCount += 1;
|
launchCount += 1;
|
||||||
|
launchedWith = authorization;
|
||||||
return GameProcessHandle(pid: 42, exitCode: exitCode.future);
|
return GameProcessHandle(pid: 42, exitCode: exitCode.future);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
|
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
|
||||||
// import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
|
// import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
|
||||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('GameInstallationService', () {
|
group('GameInstallationService', () {
|
||||||
@@ -125,5 +126,45 @@ void main() {
|
|||||||
throwsA(isA<LauncherSyncException>()),
|
throwsA(isA<LauncherSyncException>()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'launchGame passes authorization only through child environment',
|
||||||
|
() async {
|
||||||
|
final recordingService = _RecordingGameInstallationService();
|
||||||
|
final authorization = LauncherGameTicket(
|
||||||
|
account: 'PLAYERONE',
|
||||||
|
ticket: 'A1B2C3D4E5F6G7H8',
|
||||||
|
expiresAt: DateTime.now().toUtc().add(const Duration(seconds: 60)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await recordingService.launchGame(
|
||||||
|
rootDirectory.path,
|
||||||
|
authorization: authorization,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(recordingService.arguments, isEmpty);
|
||||||
|
expect(recordingService.environment, <String, String>{
|
||||||
|
'MOONWELL_LAUNCH_ACCOUNT': 'PLAYERONE',
|
||||||
|
'MOONWELL_LAUNCH_TICKET': 'A1B2C3D4E5F6G7H8',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class _RecordingGameInstallationService extends GameInstallationService {
|
||||||
|
Map<String, String>? environment;
|
||||||
|
List<String>? arguments;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<GameProcessHandle> startGameProcess({
|
||||||
|
required String executablePath,
|
||||||
|
required List<String> arguments,
|
||||||
|
required String workingDirectory,
|
||||||
|
required Map<String, String> environment,
|
||||||
|
}) async {
|
||||||
|
this.arguments = List<String>.from(arguments);
|
||||||
|
this.environment = Map<String, String>.from(environment);
|
||||||
|
return GameProcessHandle(pid: 42, exitCode: Future<int>.value(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('LauncherGameTicket', () {
|
||||||
|
test('parses the launcher authorization contract', () {
|
||||||
|
final ticket = LauncherGameTicket.fromJson(<String, Object?>{
|
||||||
|
'account': 'PLAYERONE',
|
||||||
|
'ticket': 'A1B2C3D4E5F6G7H8',
|
||||||
|
'expires_at': '2026-08-16T12:01:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ticket.account, 'PLAYERONE');
|
||||||
|
expect(ticket.ticket, 'A1B2C3D4E5F6G7H8');
|
||||||
|
expect(ticket.expiresAt, DateTime.utc(2026, 8, 16, 12, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects tickets outside the native client format', () {
|
||||||
|
expect(
|
||||||
|
() => LauncherGameTicket.fromJson(<String, Object?>{
|
||||||
|
'account': 'PLAYERONE',
|
||||||
|
'ticket': 'lowercase-ticket',
|
||||||
|
'expires_at': '2026-08-16T12:01:00Z',
|
||||||
|
}),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not normalize credentials from the API', () {
|
||||||
|
expect(
|
||||||
|
() => LauncherGameTicket.fromJson(<String, Object?>{
|
||||||
|
'account': 'PLAYER ',
|
||||||
|
'ticket': ' A1B2C3D4E5F6G7H8',
|
||||||
|
'expires_at': '2026-08-16T12:01:00Z',
|
||||||
|
}),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requires enough validity to start the game', () {
|
||||||
|
final ticket = LauncherGameTicket(
|
||||||
|
account: 'PLAYERONE',
|
||||||
|
ticket: 'A1B2C3D4E5F6G7H8',
|
||||||
|
expiresAt: DateTime.utc(2026, 8, 16, 12, 0, 4),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ticket.isUsable(now: DateTime.utc(2026, 8, 16, 12)), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-string payload fields with a format exception', () {
|
||||||
|
expect(
|
||||||
|
() => LauncherGameTicket.fromJson(<String, dynamic>{
|
||||||
|
'account': 42,
|
||||||
|
'ticket': 'ABCDEFGH12345678',
|
||||||
|
'expires_at': '2026-08-16T20:00:00Z',
|
||||||
|
}),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user