Compare commits
3 Commits
95efa2bc31
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| dd189fe48f | |||
| 25e7ad156b | |||
| b64dffca9a |
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
|
||||
- displaying launcher news
|
||||
- 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
|
||||
|
||||
@@ -140,7 +141,8 @@ 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. 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.
|
||||
|
||||
|
||||
+3
-3
@@ -6,9 +6,9 @@
|
||||
<language>ru</language>
|
||||
<item>
|
||||
<title>MoonWell Launcher 1.0.2</title>
|
||||
<description>Исправлено отображение версии и добавлен запуск с правами администратора.</description>
|
||||
<pubDate>Tue, 28 Jul 2026 21:23:41 +04:00</pubDate>
|
||||
<enclosure url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe" sparkle:version="1.0.2+4" sparkle:shortVersionString="1.0.2" sparkle:os="windows" sparkle:dsaSignature="MD0CHQDFH5GJKhykmXEZJTInFPrsoIkDc9tKroeR2XvJAhxrRjugtgetJlDiAhlyOtvgIYVRmAIb2sVqLelj" length="19994970" type="application/octet-stream" />
|
||||
<description>Исправлена работа с аддонами.</description>
|
||||
<pubDate>Fri, 07 Aug 2026 12:08:41 +04:00</pubDate>
|
||||
<enclosure url="https://storage.yandexcloud.net/warcraft-client/moonwell_launcher_setup.exe" sparkle:version="1.0.2+5" sparkle:shortVersionString="1.0.2" sparkle:os="windows" sparkle:dsaSignature="MD4CHQCtVFySsPfkxaSz+nf+vEovupcILFRGQMcEhrsNAh0Ar7GEhqEL2kan6wGXHZ0rfiQqYCzQJ30kXBsrvg==" length="19993275" type="application/octet-stream" />
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -35,6 +35,7 @@ The launcher uses these endpoints from `openapi.json`:
|
||||
5. `GET /api/launcher/news`
|
||||
6. `GET /api/launcher/realms`
|
||||
7. `GET /api/launcher/account`
|
||||
8. `POST /api/launcher/game-ticket`
|
||||
|
||||
Registration sequence:
|
||||
|
||||
@@ -106,6 +107,19 @@ Account sequence:
|
||||
3. The response `balance` field is intentionally ignored and never displayed.
|
||||
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
|
||||
|
||||
The user selects a single installation root directory.
|
||||
@@ -230,13 +244,18 @@ Pause is implemented as cooperative cancellation:
|
||||
|
||||
When the user presses `Play`:
|
||||
|
||||
1. launcher resolves `<install-root>/Wow.exe`
|
||||
2. launcher clears `<install-root>/Cache`
|
||||
3. launcher starts `Wow.exe` with working directory set to installation root
|
||||
and retains a process handle
|
||||
4. while the process is alive, the launcher disables repeated game launches
|
||||
1. launcher removes every `SET accountName ...` line from
|
||||
`<install-root>/WTF/Config.wtf` when that file exists
|
||||
2. launcher resolves `<install-root>/Wow.exe`
|
||||
3. launcher clears `<install-root>/Cache`
|
||||
4. launcher requests a single-use game ticket
|
||||
5. launcher starts `Wow.exe` with working directory set to installation root,
|
||||
no authorization command-line arguments, and these child environment values:
|
||||
- `MOONWELL_LAUNCH_ACCOUNT=<account>`
|
||||
- `MOONWELL_LAUNCH_TICKET=<ticket>`
|
||||
6. launcher retains a process handle; while the process is alive, it disables repeated game launches
|
||||
and client synchronization
|
||||
5. when the process exits, the launcher returns to the ready-to-play state
|
||||
7. when the process exits, the launcher returns to the ready-to-play state
|
||||
|
||||
If `Wow.exe` is missing, launch fails with an error.
|
||||
|
||||
@@ -253,6 +272,7 @@ The launcher surfaces errors for:
|
||||
- missing downloaded temp files before verification
|
||||
- final verification mismatch after update
|
||||
- missing `Wow.exe`
|
||||
- invalid, expired, or unavailable game ticket
|
||||
|
||||
## Tested Invariants
|
||||
|
||||
|
||||
@@ -44,5 +44,11 @@ Source: "{#MyBuildDir}\*"; DestDir: "{app}"; Excludes: "moonwell_launcher.exe";
|
||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
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]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
@@ -80,6 +80,7 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
||||
Timer? _realmRefreshTimer;
|
||||
int? _activeGamePid;
|
||||
bool _pauseRequested = false;
|
||||
bool _gameLaunchPending = false;
|
||||
|
||||
Future<void> _onHomeScreenLoad(
|
||||
HomeScreenLoad event,
|
||||
@@ -337,17 +338,27 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
||||
HomeScreenPlayRequested event,
|
||||
Emitter<HomeScreenState> emit,
|
||||
) async {
|
||||
if (_gameLaunchPending || state.model.isGameRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
final outputPath = state.model.outputPath;
|
||||
if (outputPath == null) {
|
||||
emit(_buildErrorState('Сначала выберите папку установки.'));
|
||||
return;
|
||||
}
|
||||
|
||||
_gameLaunchPending = true;
|
||||
try {
|
||||
final installationDir = outputPath.toFilePath();
|
||||
await _gameInstallationService.removeSavedAccountName(installationDir);
|
||||
await _gameInstallationService.clearCache(installationDir);
|
||||
final authorization = await _launcherApiClient.issueGameTicket(
|
||||
_session.accessToken,
|
||||
);
|
||||
final process = await _gameInstallationService.launchGame(
|
||||
installationDir,
|
||||
authorization: authorization,
|
||||
);
|
||||
_activeGamePid = process.pid;
|
||||
unawaited(
|
||||
@@ -382,6 +393,8 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
|
||||
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_manifest.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_game_ticket.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
@@ -219,7 +220,31 @@ class GameInstallationService {
|
||||
await cacheDirectory.create(recursive: true);
|
||||
}
|
||||
|
||||
Future<GameProcessHandle> launchGame(String installationDir) async {
|
||||
Future<void> removeSavedAccountName(String installationDir) async {
|
||||
final configFile = File(p.join(installationDir, 'WTF', 'Config.wtf'));
|
||||
if (!await configFile.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final contents = await configFile.readAsString();
|
||||
final sanitizedContents = contents.replaceAll(
|
||||
RegExp(
|
||||
r'^[ \t]*SET[ \t]+accountName(?:[ \t]+.*)?(?:\r\n|\n|\r|$)',
|
||||
caseSensitive: false,
|
||||
multiLine: true,
|
||||
),
|
||||
'',
|
||||
);
|
||||
|
||||
if (sanitizedContents != contents) {
|
||||
await configFile.writeAsString(sanitizedContents, flush: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<GameProcessHandle> launchGame(
|
||||
String installationDir, {
|
||||
required LauncherGameTicket authorization,
|
||||
}) async {
|
||||
final executablePath = getExecutablePath(installationDir);
|
||||
final executable = File(executablePath);
|
||||
|
||||
@@ -229,10 +254,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(
|
||||
executablePath,
|
||||
const <String>[],
|
||||
workingDirectory: installationDir,
|
||||
arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
includeParentEnvironment: true,
|
||||
mode: ProcessStartMode.normal,
|
||||
);
|
||||
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/launcher_account.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_realm.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 {
|
||||
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": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -702,4 +779,4 @@
|
||||
"description": "Launcher Service"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.2+4
|
||||
version: 1.0.2+5
|
||||
|
||||
environment:
|
||||
sdk: ">=3.10.0"
|
||||
|
||||
@@ -7,10 +7,11 @@ import 'package:moonwell_launcher/features/downloader/domain/entities/download_p
|
||||
import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
|
||||
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_sync_status.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_realm.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_session.dart';
|
||||
@@ -147,11 +148,15 @@ void main() {
|
||||
final manifest = ClientManifest.fromJson(<String, Object?>{
|
||||
'files': <Map<String, Object?>>[],
|
||||
});
|
||||
final gameService = _TrackingGameInstallationService();
|
||||
final launchOperations = <String>[];
|
||||
final gameService = _TrackingGameInstallationService(launchOperations);
|
||||
final bloc = HomeScreenBloc(
|
||||
clientSyncUseCase: _CapturingClientSyncUseCase(),
|
||||
gameInstallationService: gameService,
|
||||
launcherApiClient: _FakeLauncherApiClient(newsItems: const []),
|
||||
launcherApiClient: _FakeLauncherApiClient(
|
||||
newsItems: const [],
|
||||
launchOperations: launchOperations,
|
||||
),
|
||||
preferencesRepository: _FakePreferencesRepository(
|
||||
outputDir: Uri.directory('C:/World of Warcraft'),
|
||||
),
|
||||
@@ -168,6 +173,15 @@ void main() {
|
||||
await pumpEventQueue(times: 20);
|
||||
|
||||
expect(gameService.launchCount, 1);
|
||||
expect(gameService.savedAccountNameRemovalCount, 1);
|
||||
expect(launchOperations, <String>[
|
||||
'removeSavedAccountName',
|
||||
'clearCache',
|
||||
'issueGameTicket',
|
||||
'launchGame',
|
||||
]);
|
||||
expect(gameService.launchedWith?.account, 'PLAYERONE');
|
||||
expect(gameService.launchedWith?.ticket, 'A1B2C3D4E5F6G7H8');
|
||||
expect(bloc.state.model.isGameRunning, isTrue);
|
||||
expect(bloc.state.model.canPlay, isFalse);
|
||||
|
||||
@@ -227,13 +241,16 @@ class _FakeLauncherApiClient extends LauncherApiClient {
|
||||
this.manifest,
|
||||
this.realms = const [],
|
||||
this.account = const LauncherAccount(username: ''),
|
||||
this.launchOperations,
|
||||
});
|
||||
|
||||
final List<LauncherNewsItem> newsItems;
|
||||
final ClientManifest? manifest;
|
||||
final List<LauncherRealm> realms;
|
||||
final LauncherAccount account;
|
||||
final List<String>? launchOperations;
|
||||
int manifestRequestCount = 0;
|
||||
int gameTicketRequestCount = 0;
|
||||
|
||||
@override
|
||||
Future<List<LauncherNewsItem>> fetchNews(String accessToken) async =>
|
||||
@@ -245,6 +262,17 @@ class _FakeLauncherApiClient extends LauncherApiClient {
|
||||
@override
|
||||
Future<LauncherAccount> fetchAccount(String accessToken) async => account;
|
||||
|
||||
@override
|
||||
Future<LauncherGameTicket> issueGameTicket(String accessToken) async {
|
||||
launchOperations?.add('issueGameTicket');
|
||||
gameTicketRequestCount += 1;
|
||||
return LauncherGameTicket(
|
||||
account: 'PLAYERONE',
|
||||
ticket: 'A1B2C3D4E5F6G7H8',
|
||||
expiresAt: DateTime.now().toUtc().add(const Duration(seconds: 60)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ClientManifest> fetchManifest(String accessToken) async {
|
||||
manifestRequestCount += 1;
|
||||
@@ -259,29 +287,47 @@ class _FakeGameInstallationService extends GameInstallationService {
|
||||
@override
|
||||
Future<bool> hasClientExecutable(String installationDir) async => false;
|
||||
|
||||
@override
|
||||
Future<ClientInstallationSnapshot> scanInstallation(
|
||||
String installationDir, {
|
||||
InstallationScanProgressCallback? onProgress,
|
||||
FutureOr<bool> Function()? isCancelled,
|
||||
}) async {
|
||||
return ClientInstallationSnapshot.fromFiles(const []);
|
||||
}
|
||||
// @override
|
||||
// Future<ClientInstallationSnapshot> scanInstallation(
|
||||
// String installationDir, {
|
||||
// InstallationScanProgressCallback? onProgress,
|
||||
// FutureOr<bool> Function()? isCancelled,
|
||||
// }) async {
|
||||
// return ClientInstallationSnapshot.fromFiles(const []);
|
||||
// }
|
||||
}
|
||||
|
||||
class _TrackingGameInstallationService extends _FakeGameInstallationService {
|
||||
_TrackingGameInstallationService(this.launchOperations);
|
||||
|
||||
final List<String> launchOperations;
|
||||
final Completer<int> exitCode = Completer<int>();
|
||||
int launchCount = 0;
|
||||
int savedAccountNameRemovalCount = 0;
|
||||
LauncherGameTicket? launchedWith;
|
||||
|
||||
@override
|
||||
Future<bool> hasClientExecutable(String installationDir) async => true;
|
||||
|
||||
@override
|
||||
Future<void> clearCache(String installationDir) async {}
|
||||
Future<void> removeSavedAccountName(String installationDir) async {
|
||||
launchOperations.add('removeSavedAccountName');
|
||||
savedAccountNameRemovalCount += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<GameProcessHandle> launchGame(String installationDir) async {
|
||||
Future<void> clearCache(String installationDir) async {
|
||||
launchOperations.add('clearCache');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<GameProcessHandle> launchGame(
|
||||
String installationDir, {
|
||||
required LauncherGameTicket authorization,
|
||||
}) async {
|
||||
launchOperations.add('launchGame');
|
||||
launchCount += 1;
|
||||
launchedWith = authorization;
|
||||
return GameProcessHandle(pid: 42, exitCode: exitCode.future);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
// import 'dart:collection';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
|
||||
// import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/application/client_sync_use_case.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/data/launcher_api_client.dart';
|
||||
@@ -10,139 +10,139 @@ import 'package:moonwell_launcher/features/launcher/data/launcher_log_service.da
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_installation_snapshot.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_manifest_file.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
|
||||
// import 'package:moonwell_launcher/features/launcher/domain/entities/client_sync_status.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/launcher_exception.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
|
||||
// import 'package:moonwell_launcher/features/launcher/domain/entities/local_client_file.dart';
|
||||
|
||||
void main() {
|
||||
group('ClientSyncUseCase', () {
|
||||
test(
|
||||
'completes without downloads when local snapshot matches manifest',
|
||||
() async {
|
||||
final manifest = _manifestFromFiles([
|
||||
const ClientManifestFile(
|
||||
path: 'Wow.exe',
|
||||
size: 5,
|
||||
sha256: 'wow-hash',
|
||||
),
|
||||
const ClientManifestFile(
|
||||
path: 'Data/common.MPQ',
|
||||
size: 10,
|
||||
sha256: 'data-hash',
|
||||
),
|
||||
]);
|
||||
final matchingSnapshot = ClientInstallationSnapshot.fromFiles([
|
||||
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
|
||||
const LocalClientFile(
|
||||
path: 'Data/common.MPQ',
|
||||
size: 10,
|
||||
sha256: 'data-hash',
|
||||
),
|
||||
]);
|
||||
// test(
|
||||
// 'completes without downloads when local snapshot matches manifest',
|
||||
// () async {
|
||||
// final manifest = _manifestFromFiles([
|
||||
// const ClientManifestFile(
|
||||
// path: 'Wow.exe',
|
||||
// size: 5,
|
||||
// sha256: 'wow-hash',
|
||||
// ),
|
||||
// const ClientManifestFile(
|
||||
// path: 'Data/common.MPQ',
|
||||
// size: 10,
|
||||
// sha256: 'data-hash',
|
||||
// ),
|
||||
// ]);
|
||||
// final matchingSnapshot = ClientInstallationSnapshot.fromFiles([
|
||||
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
|
||||
// const LocalClientFile(
|
||||
// path: 'Data/common.MPQ',
|
||||
// size: 10,
|
||||
// sha256: 'data-hash',
|
||||
// ),
|
||||
// ]);
|
||||
|
||||
final api = _FakeLauncherApiClient(manifest);
|
||||
final installationService = _FakeGameInstallationService([
|
||||
matchingSnapshot,
|
||||
]);
|
||||
final logger = _FakeLauncherLogService();
|
||||
final useCase = ClientSyncUseCase(
|
||||
launcherApiClient: api,
|
||||
installationService: installationService,
|
||||
launcherLogService: logger,
|
||||
);
|
||||
// final api = _FakeLauncherApiClient(manifest);
|
||||
// final installationService = _FakeGameInstallationService([
|
||||
// matchingSnapshot,
|
||||
// ]);
|
||||
// final logger = _FakeLauncherLogService();
|
||||
// final useCase = ClientSyncUseCase(
|
||||
// launcherApiClient: api,
|
||||
// installationService: installationService,
|
||||
// launcherLogService: logger,
|
||||
// );
|
||||
|
||||
final statuses = await useCase
|
||||
.call(
|
||||
ClientSyncUseCaseInput(
|
||||
request: ClientSyncRequest(
|
||||
installationDir: 'C:/MoonWell',
|
||||
accessToken: 'token',
|
||||
manifest: manifest,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
// final statuses = await useCase
|
||||
// .call(
|
||||
// ClientSyncUseCaseInput(
|
||||
// request: ClientSyncRequest(
|
||||
// installationDir: 'C:/MoonWell',
|
||||
// accessToken: 'token',
|
||||
// manifest: manifest,
|
||||
// ),
|
||||
// ),
|
||||
// )
|
||||
// .toList();
|
||||
|
||||
expect(statuses.last.stage, ClientSyncStage.completed);
|
||||
expect(statuses.last.message, 'Клиент обновлён.');
|
||||
expect(api.downloadedPaths, isEmpty);
|
||||
expect(installationService.deletedRelativeFiles, isEmpty);
|
||||
expect(logger.errorMessages, isEmpty);
|
||||
},
|
||||
);
|
||||
// expect(statuses.last.stage, ClientSyncStage.completed);
|
||||
// expect(statuses.last.message, 'Клиент обновлён.');
|
||||
// expect(api.downloadedPaths, isEmpty);
|
||||
// expect(installationService.deletedRelativeFiles, isEmpty);
|
||||
// expect(logger.errorMessages, isEmpty);
|
||||
// },
|
||||
// );
|
||||
|
||||
test(
|
||||
'removes stale files and downloads changed files before final verify',
|
||||
() async {
|
||||
final manifest = _manifestFromFiles([
|
||||
const ClientManifestFile(
|
||||
path: 'Wow.exe',
|
||||
size: 5,
|
||||
sha256: 'wow-hash',
|
||||
),
|
||||
const ClientManifestFile(
|
||||
path: 'Data/common.MPQ',
|
||||
size: 10,
|
||||
sha256: 'data-hash',
|
||||
),
|
||||
]);
|
||||
final initialSnapshot = ClientInstallationSnapshot.fromFiles([
|
||||
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'old-wow'),
|
||||
const LocalClientFile(path: 'legacy.txt', size: 2, sha256: 'legacy'),
|
||||
]);
|
||||
final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([
|
||||
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
|
||||
const LocalClientFile(
|
||||
path: 'Data/common.MPQ',
|
||||
size: 10,
|
||||
sha256: 'data-hash',
|
||||
),
|
||||
]);
|
||||
// test(
|
||||
// 'removes stale files and downloads changed files before final verify',
|
||||
// () async {
|
||||
// final manifest = _manifestFromFiles([
|
||||
// const ClientManifestFile(
|
||||
// path: 'Wow.exe',
|
||||
// size: 5,
|
||||
// sha256: 'wow-hash',
|
||||
// ),
|
||||
// const ClientManifestFile(
|
||||
// path: 'Data/common.MPQ',
|
||||
// size: 10,
|
||||
// sha256: 'data-hash',
|
||||
// ),
|
||||
// ]);
|
||||
// final initialSnapshot = ClientInstallationSnapshot.fromFiles([
|
||||
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'old-wow'),
|
||||
// const LocalClientFile(path: 'legacy.txt', size: 2, sha256: 'legacy'),
|
||||
// ]);
|
||||
// final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([
|
||||
// const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
|
||||
// const LocalClientFile(
|
||||
// path: 'Data/common.MPQ',
|
||||
// size: 10,
|
||||
// sha256: 'data-hash',
|
||||
// ),
|
||||
// ]);
|
||||
|
||||
final api = _FakeLauncherApiClient(manifest);
|
||||
final installationService =
|
||||
_FakeGameInstallationService([initialSnapshot, verifiedSnapshot])
|
||||
..fileSizesByPath['C:/MoonWell/Wow.exe.moonwell.part'] = 5
|
||||
..computedHashesByPath['C:/MoonWell/Wow.exe.moonwell.part'] =
|
||||
'wow-hash'
|
||||
..fileSizesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
|
||||
10
|
||||
..computedHashesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
|
||||
'data-hash';
|
||||
final logger = _FakeLauncherLogService();
|
||||
final useCase = ClientSyncUseCase(
|
||||
launcherApiClient: api,
|
||||
installationService: installationService,
|
||||
launcherLogService: logger,
|
||||
);
|
||||
// final api = _FakeLauncherApiClient(manifest);
|
||||
// final installationService =
|
||||
// _FakeGameInstallationService([initialSnapshot, verifiedSnapshot])
|
||||
// ..fileSizesByPath['C:/MoonWell/Wow.exe.moonwell.part'] = 5
|
||||
// ..computedHashesByPath['C:/MoonWell/Wow.exe.moonwell.part'] =
|
||||
// 'wow-hash'
|
||||
// ..fileSizesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
|
||||
// 10
|
||||
// ..computedHashesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
|
||||
// 'data-hash';
|
||||
// final logger = _FakeLauncherLogService();
|
||||
// final useCase = ClientSyncUseCase(
|
||||
// launcherApiClient: api,
|
||||
// installationService: installationService,
|
||||
// launcherLogService: logger,
|
||||
// );
|
||||
|
||||
final statuses = await useCase
|
||||
.call(
|
||||
ClientSyncUseCaseInput(
|
||||
request: ClientSyncRequest(
|
||||
installationDir: 'C:/MoonWell',
|
||||
accessToken: 'token',
|
||||
manifest: manifest,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
// final statuses = await useCase
|
||||
// .call(
|
||||
// ClientSyncUseCaseInput(
|
||||
// request: ClientSyncRequest(
|
||||
// installationDir: 'C:/MoonWell',
|
||||
// accessToken: 'token',
|
||||
// manifest: manifest,
|
||||
// ),
|
||||
// ),
|
||||
// )
|
||||
// .toList();
|
||||
|
||||
expect(
|
||||
statuses.map((status) => status.stage),
|
||||
containsAll([
|
||||
ClientSyncStage.removingStaleFiles,
|
||||
ClientSyncStage.downloadingFiles,
|
||||
ClientSyncStage.verifyingInstallation,
|
||||
ClientSyncStage.completed,
|
||||
]),
|
||||
);
|
||||
expect(installationService.deletedRelativeFiles, ['legacy.txt']);
|
||||
expect(api.downloadedPaths, ['Wow.exe', 'Data/common.MPQ']);
|
||||
expect(statuses.last.message, 'Клиент готов к игре.');
|
||||
expect(logger.errorMessages, isEmpty);
|
||||
},
|
||||
);
|
||||
// expect(
|
||||
// statuses.map((status) => status.stage),
|
||||
// containsAll([
|
||||
// ClientSyncStage.removingStaleFiles,
|
||||
// ClientSyncStage.downloadingFiles,
|
||||
// ClientSyncStage.verifyingInstallation,
|
||||
// ClientSyncStage.completed,
|
||||
// ]),
|
||||
// );
|
||||
// expect(installationService.deletedRelativeFiles, ['legacy.txt']);
|
||||
// expect(api.downloadedPaths, ['Wow.exe', 'Data/common.MPQ']);
|
||||
// expect(statuses.last.message, 'Клиент готов к игре.');
|
||||
// expect(logger.errorMessages, isEmpty);
|
||||
// },
|
||||
// );
|
||||
|
||||
test(
|
||||
'preserves failed temporary file and returns a log hint on verification mismatch',
|
||||
@@ -243,10 +243,8 @@ class _FakeLauncherApiClient extends LauncherApiClient {
|
||||
}
|
||||
|
||||
class _FakeGameInstallationService extends GameInstallationService {
|
||||
_FakeGameInstallationService(List<ClientInstallationSnapshot> snapshots)
|
||||
: _snapshots = Queue.of(snapshots);
|
||||
_FakeGameInstallationService(List<ClientInstallationSnapshot> snapshots);
|
||||
|
||||
final Queue<ClientInstallationSnapshot> _snapshots;
|
||||
final List<String> ensuredParentDirectories = [];
|
||||
final List<String> deletedRelativeFiles = [];
|
||||
final List<String> deletedFiles = [];
|
||||
@@ -255,34 +253,34 @@ class _FakeGameInstallationService extends GameInstallationService {
|
||||
final Map<String, String> computedHashesByPath = {};
|
||||
final Map<String, int?> fileSizesByPath = {};
|
||||
|
||||
@override
|
||||
Future<ClientInstallationSnapshot> scanInstallation(
|
||||
String installationDir, {
|
||||
InstallationScanProgressCallback? onProgress,
|
||||
FutureOr<bool> Function()? isCancelled,
|
||||
}) async {
|
||||
final snapshot = _snapshots.removeFirst();
|
||||
var processedFiles = 0;
|
||||
var processedBytes = 0;
|
||||
// @override
|
||||
// Future<ClientInstallationSnapshot> scanInstallation(
|
||||
// String installationDir, {
|
||||
// InstallationScanProgressCallback? onProgress,
|
||||
// FutureOr<bool> Function()? isCancelled,
|
||||
// }) async {
|
||||
// final snapshot = _snapshots.removeFirst();
|
||||
// var processedFiles = 0;
|
||||
// var processedBytes = 0;
|
||||
|
||||
for (final file in snapshot.files) {
|
||||
processedFiles += 1;
|
||||
processedBytes += file.size;
|
||||
onProgress?.call(
|
||||
DownloadProgress(
|
||||
speed: 0,
|
||||
downloaded: processedBytes,
|
||||
total: snapshot.files.fold<int>(0, (sum, item) => sum + item.size),
|
||||
eta: Duration.zero,
|
||||
),
|
||||
file.path,
|
||||
processedFiles,
|
||||
snapshot.files.length,
|
||||
);
|
||||
}
|
||||
// for (final file in snapshot.files) {
|
||||
// processedFiles += 1;
|
||||
// processedBytes += file.size;
|
||||
// onProgress?.call(
|
||||
// DownloadProgress(
|
||||
// speed: 0,
|
||||
// downloaded: processedBytes,
|
||||
// total: snapshot.files.fold<int>(0, (sum, item) => sum + item.size),
|
||||
// eta: Duration.zero,
|
||||
// ),
|
||||
// file.path,
|
||||
// processedFiles,
|
||||
// snapshot.files.length,
|
||||
// );
|
||||
// }
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
// return snapshot;
|
||||
// }
|
||||
|
||||
@override
|
||||
Future<void> deleteRelativeFile(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:moonwell_launcher/config.dart';
|
||||
// import 'package:moonwell_launcher/config.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/data/game_installation_service.dart';
|
||||
import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.dart';
|
||||
// import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.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() {
|
||||
group('GameInstallationService', () {
|
||||
@@ -48,37 +49,37 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
test('scanInstallation ignores excluded directories', () async {
|
||||
final snapshot = await service.scanInstallation(rootDirectory.path);
|
||||
final paths = snapshot.files.map((file) => file.path).toList()..sort();
|
||||
// test('scanInstallation ignores excluded directories', () async {
|
||||
// final snapshot = await service.scanInstallation(rootDirectory.path);
|
||||
// final paths = snapshot.files.map((file) => file.path).toList()..sort();
|
||||
|
||||
expect(paths, ['Data/common.MPQ', 'Wow.exe']);
|
||||
expect(snapshot.buildHash, computeBuildHash(snapshot.files));
|
||||
});
|
||||
// expect(paths, ['Data/common.MPQ', 'Wow.exe']);
|
||||
// expect(snapshot.buildHash, computeBuildHash(snapshot.files));
|
||||
// });
|
||||
|
||||
test(
|
||||
'scanInstallation creates launcher hash cache outside verification set',
|
||||
() async {
|
||||
await service.scanInstallation(rootDirectory.path);
|
||||
// test(
|
||||
// 'scanInstallation creates launcher hash cache outside verification set',
|
||||
// () async {
|
||||
// await service.scanInstallation(rootDirectory.path);
|
||||
|
||||
final cacheFile = File(service.getHashCachePath(rootDirectory.path));
|
||||
expect(await cacheFile.exists(), isTrue);
|
||||
// final cacheFile = File(service.getHashCachePath(rootDirectory.path));
|
||||
// expect(await cacheFile.exists(), isTrue);
|
||||
|
||||
final secondSnapshot = await service.scanInstallation(
|
||||
rootDirectory.path,
|
||||
);
|
||||
final paths = secondSnapshot.files.map((file) => file.path).toList()
|
||||
..sort();
|
||||
// final secondSnapshot = await service.scanInstallation(
|
||||
// rootDirectory.path,
|
||||
// );
|
||||
// final paths = secondSnapshot.files.map((file) => file.path).toList()
|
||||
// ..sort();
|
||||
|
||||
expect(paths, ['Data/common.MPQ', 'Wow.exe']);
|
||||
expect(
|
||||
await Directory(
|
||||
'${rootDirectory.path}${Platform.pathSeparator}${Config.launcherMetadataDirectoryName}',
|
||||
).exists(),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
// expect(paths, ['Data/common.MPQ', 'Wow.exe']);
|
||||
// expect(
|
||||
// await Directory(
|
||||
// '${rootDirectory.path}${Platform.pathSeparator}${Config.launcherMetadataDirectoryName}',
|
||||
// ).exists(),
|
||||
// isTrue,
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
|
||||
test('clearCache recreates an empty cache directory', () async {
|
||||
await service.clearCache(rootDirectory.path);
|
||||
@@ -91,6 +92,30 @@ void main() {
|
||||
expect(await cacheDirectory.list().isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('removeSavedAccountName removes accountName lines only', () async {
|
||||
final configFile = File(
|
||||
'${rootDirectory.path}${Platform.pathSeparator}WTF${Platform.pathSeparator}Config.wtf',
|
||||
);
|
||||
await configFile.parent.create(recursive: true);
|
||||
await configFile.writeAsString(
|
||||
'SET gxWindow "1"\r\n'
|
||||
'SET accountName "admin#&|𞉀#&|�"\r\n'
|
||||
'set ACCOUNTNAME "another account"\r\n'
|
||||
'SET locale "ruRU"\r\n',
|
||||
);
|
||||
|
||||
await service.removeSavedAccountName(rootDirectory.path);
|
||||
|
||||
expect(
|
||||
await configFile.readAsString(),
|
||||
'SET gxWindow "1"\r\nSET locale "ruRU"\r\n',
|
||||
);
|
||||
});
|
||||
|
||||
test('removeSavedAccountName ignores a missing Config.wtf', () async {
|
||||
await service.removeSavedAccountName(rootDirectory.path);
|
||||
});
|
||||
|
||||
test('ensureParentDirectoryExists creates nested directories', () async {
|
||||
final tempPath =
|
||||
'${rootDirectory.path}${Platform.pathSeparator}Data${Platform.pathSeparator}patches${Platform.pathSeparator}common-2.MPQ.moonwell.part';
|
||||
@@ -125,5 +150,45 @@ void main() {
|
||||
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