5 Commits

17 changed files with 688 additions and 247 deletions
+3 -2
View File
@@ -1,3 +1,4 @@
{ {
"dart.flutterSdkPath": ".fvm/versions/stable" "dart.flutterSdkPath": ".fvm/versions/stable",
} "cmake.sourceDirectory": "C:/Users/sindo/moonwell_launcher/windows"
}
+4 -2
View File
@@ -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.
+3 -3
View File
@@ -6,9 +6,9 @@
<language>ru</language> <language>ru</language>
<item> <item>
<title>MoonWell Launcher 1.0.2</title> <title>MoonWell Launcher 1.0.2</title>
<description>Исправлено отображение версии и добавлен запуск с правами администратора.</description> <description>Исправлена работа с аддонами.</description>
<pubDate>Tue, 28 Jul 2026 21:23:41 +04:00</pubDate> <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+4" sparkle:shortVersionString="1.0.2" sparkle:os="windows" sparkle:dsaSignature="MD0CHQDFH5GJKhykmXEZJTInFPrsoIkDc9tKroeR2XvJAhxrRjugtgetJlDiAhlyOtvgIYVRmAIb2sVqLelj" length="19994970" type="application/octet-stream" /> <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> </item>
</channel> </channel>
</rss> </rss>
+26 -6
View File
@@ -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.
@@ -230,13 +244,18 @@ Pause is implemented as cooperative cancellation:
When the user presses `Play`: When the user presses `Play`:
1. launcher resolves `<install-root>/Wow.exe` 1. launcher removes every `SET accountName ...` line from
2. launcher clears `<install-root>/Cache` `<install-root>/WTF/Config.wtf` when that file exists
3. launcher starts `Wow.exe` with working directory set to installation root 2. launcher resolves `<install-root>/Wow.exe`
and retains a process handle 3. launcher clears `<install-root>/Cache`
4. while the process is alive, the launcher disables repeated game launches 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 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. 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 - 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
+6
View File
@@ -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,27 @@ 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.removeSavedAccountName(installationDir);
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 +393,8 @@ class HomeScreenBloc extends Bloc<HomeScreenEvent, HomeScreenState> {
errorMessage: _formatError(error), errorMessage: _formatError(error),
), ),
); );
} finally {
_gameLaunchPending = false;
} }
} }
+1
View File
@@ -11,6 +11,7 @@ final class Config {
static const launcherMetadataDirectoryName = '.moonwell_launcher'; static const launcherMetadataDirectoryName = '.moonwell_launcher';
static const hashCacheFileName = 'hash_cache.json'; static const hashCacheFileName = 'hash_cache.json';
static const ignoredVerificationDirectories = <String>{ static const ignoredVerificationDirectories = <String>{
'addons',
'cache', 'cache',
'errors', 'errors',
'logs', 'logs',
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/config.dart';
import 'package:moonwell_launcher/core/use_case.dart'; import 'package:moonwell_launcher/core/use_case.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_progress.dart';
@@ -95,6 +96,7 @@ class ClientSyncUseCase
final localSnapshot = await _installationService.scanInstallation( final localSnapshot = await _installationService.scanInstallation(
input.request.installationDir, input.request.installationDir,
isCancelled: input.isCancelled, isCancelled: input.isCancelled,
manifest: manifest,
onProgress: (progress, currentPath, processedFiles, totalFiles) { onProgress: (progress, currentPath, processedFiles, totalFiles) {
_emit( _emit(
controller, controller,
@@ -185,6 +187,7 @@ class ClientSyncUseCase
final verifiedSnapshot = await _installationService.scanInstallation( final verifiedSnapshot = await _installationService.scanInstallation(
input.request.installationDir, input.request.installationDir,
isCancelled: input.isCancelled, isCancelled: input.isCancelled,
manifest: manifest,
onProgress: (progress, currentPath, processedFiles, totalFiles) { onProgress: (progress, currentPath, processedFiles, totalFiles) {
_emit( _emit(
controller, controller,
@@ -279,7 +282,13 @@ class ClientSyncUseCase
final remoteFiles = manifest.filesByPath; final remoteFiles = manifest.filesByPath;
return snapshot.files return snapshot.files
.where((localFile) => !remoteFiles.containsKey(localFile.path)) .where((localFile) {
if (remoteFiles.containsKey(localFile.path)) {
return false;
}
return !_isIgnored(localFile.path);
})
.map((file) => file.path) .map((file) => file.path)
.toList(); .toList();
} }
@@ -454,14 +463,21 @@ class ClientSyncUseCase
final mismatchedFiles = manifest.files.where((remoteFile) { final mismatchedFiles = manifest.files.where((remoteFile) {
final localFile = localFilesByPath[remoteFile.path]; final localFile = localFilesByPath[remoteFile.path];
return localFile == null || final isMismatched =
localFile == null ||
localFile.sha256 != remoteFile.sha256 || localFile.sha256 != remoteFile.sha256 ||
localFile.size != remoteFile.size; localFile.size != remoteFile.size;
return isMismatched;
}).toList(); }).toList();
final extraFiles = snapshot.files final extraFiles = snapshot.files.where((localFile) {
.where((localFile) => !manifest.filesByPath.containsKey(localFile.path)) if (manifest.filesByPath.containsKey(localFile.path)) {
.toList(); return false;
}
return !_isIgnored(localFile.path);
}).toList();
if (snapshot.buildHash != remoteBuildHash || if (snapshot.buildHash != remoteBuildHash ||
mismatchedFiles.isNotEmpty || mismatchedFiles.isNotEmpty ||
@@ -492,6 +508,15 @@ class ClientSyncUseCase
} }
} }
/// Файлы, которые не будут удалены во время проверки файлов. Например, пользовательские
/// аддоны, скриншоты и т.д.
bool _isIgnored(String filePath) {
final normalizedPath = filePath.replaceAll('\\', '/').toLowerCase();
return Config.ignoredVerificationDirectories.any(
(pattern) => normalizedPath.contains(pattern.toLowerCase()),
);
}
Future<void> _verifyDownloadedFile({ Future<void> _verifyDownloadedFile({
required String installationDir, required String installationDir,
required ClientManifestFile file, required ClientManifestFile file,
@@ -8,10 +8,12 @@ import 'package:injectable/injectable.dart';
import 'package:moonwell_launcher/config.dart'; import 'package:moonwell_launcher/config.dart';
import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.dart'; import 'package:moonwell_launcher/features/downloader/domain/entities/download_exceptions.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/domain/entities/client_installation_snapshot.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/client_hash_entry.dart'; import 'package:moonwell_launcher/features/launcher/domain/entities/client_hash_entry.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/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; import 'package:path/path.dart' as p;
typedef InstallationScanProgressCallback = typedef InstallationScanProgressCallback =
@@ -33,6 +35,7 @@ final class GameProcessHandle {
class GameInstallationService { class GameInstallationService {
Future<ClientInstallationSnapshot> scanInstallation( Future<ClientInstallationSnapshot> scanInstallation(
String installationDir, { String installationDir, {
required ClientManifest manifest,
InstallationScanProgressCallback? onProgress, InstallationScanProgressCallback? onProgress,
FutureOr<bool> Function()? isCancelled, FutureOr<bool> Function()? isCancelled,
}) async { }) async {
@@ -132,6 +135,7 @@ class GameInstallationService {
'hashCachePath': hashCachePath, 'hashCachePath': hashCachePath,
'cachedHashes': cachedHashes, 'cachedHashes': cachedHashes,
'ignoredDirectories': Config.ignoredVerificationDirectories.toList(), 'ignoredDirectories': Config.ignoredVerificationDirectories.toList(),
'serverFiles': manifest.files.map((f) => f.path).toList(),
}, },
onError: errorPort.sendPort, onError: errorPort.sendPort,
onExit: exitPort.sendPort, onExit: exitPort.sendPort,
@@ -216,7 +220,31 @@ class GameInstallationService {
await cacheDirectory.create(recursive: true); 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 executablePath = getExecutablePath(installationDir);
final executable = File(executablePath); final executable = File(executablePath);
@@ -226,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( 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();
@@ -343,6 +390,9 @@ Future<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
.whereType<String>() .whereType<String>()
.map((entry) => entry.toLowerCase()) .map((entry) => entry.toLowerCase())
.toSet(); .toSet();
final serverFiles = (message['serverFiles'] as List<Object?>)
.whereType<String>()
.toSet();
final rootDirectory = Directory(installationDir); final rootDirectory = Directory(installationDir);
if (!await rootDirectory.exists()) { if (!await rootDirectory.exists()) {
@@ -356,6 +406,7 @@ Future<void> _scanInstallationIsolateMain(Map<String, Object?> message) async {
rootDirectory.path, rootDirectory.path,
pendingFiles, pendingFiles,
ignoredDirectories, ignoredDirectories,
serverFiles,
); );
pendingFiles.sort( pendingFiles.sort(
@@ -424,41 +475,21 @@ Future<void> _collectFilesInIsolate(
String rootPath, String rootPath,
List<_SerializablePendingFile> pendingFiles, List<_SerializablePendingFile> pendingFiles,
Set<String> ignoredDirectories, Set<String> ignoredDirectories,
Set<String> serverFiles,
) async { ) async {
await for (final entity in directory.list(followLinks: false)) { for (final path in serverFiles) {
final relativePath = normalizeClientPath( final file = File('$rootPath/$path');
p.relative(entity.path, from: rootPath),
);
if (relativePath.isEmpty || relativePath == '.') { if (!await file.exists()) {
continue; continue;
} }
final segments = p.posix.split(relativePath); final stat = await file.stat();
if (segments.isNotEmpty &&
ignoredDirectories.contains(segments.first.toLowerCase())) {
continue;
}
if (entity is Directory) {
await _collectFilesInIsolate(
entity,
rootPath,
pendingFiles,
ignoredDirectories,
);
continue;
}
if (entity is! File) {
continue;
}
final stat = await entity.stat();
pendingFiles.add( pendingFiles.add(
_SerializablePendingFile( _SerializablePendingFile(
path: entity.path, path: file.path,
relativePath: relativePath, relativePath: normalizeClientPath(p.relative(path)),
size: stat.size, size: stat.size,
modifiedMs: stat.modified.millisecondsSinceEpoch, modifiedMs: stat.modified.millisecondsSinceEpoch,
), ),
@@ -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
View File
@@ -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"
} }
] ]
} }
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # 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 # 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. # 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: environment:
sdk: ">=3.10.0" 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/application/client_sync_use_case.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/data/launcher_api_client.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_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';
@@ -147,11 +148,15 @@ void main() {
final manifest = ClientManifest.fromJson(<String, Object?>{ final manifest = ClientManifest.fromJson(<String, Object?>{
'files': <Map<String, Object?>>[], 'files': <Map<String, Object?>>[],
}); });
final gameService = _TrackingGameInstallationService(); final launchOperations = <String>[];
final gameService = _TrackingGameInstallationService(launchOperations);
final bloc = HomeScreenBloc( final bloc = HomeScreenBloc(
clientSyncUseCase: _CapturingClientSyncUseCase(), clientSyncUseCase: _CapturingClientSyncUseCase(),
gameInstallationService: gameService, gameInstallationService: gameService,
launcherApiClient: _FakeLauncherApiClient(newsItems: const []), launcherApiClient: _FakeLauncherApiClient(
newsItems: const [],
launchOperations: launchOperations,
),
preferencesRepository: _FakePreferencesRepository( preferencesRepository: _FakePreferencesRepository(
outputDir: Uri.directory('C:/World of Warcraft'), outputDir: Uri.directory('C:/World of Warcraft'),
), ),
@@ -168,6 +173,15 @@ void main() {
await pumpEventQueue(times: 20); await pumpEventQueue(times: 20);
expect(gameService.launchCount, 1); 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.isGameRunning, isTrue);
expect(bloc.state.model.canPlay, isFalse); expect(bloc.state.model.canPlay, isFalse);
@@ -227,13 +241,16 @@ class _FakeLauncherApiClient extends LauncherApiClient {
this.manifest, this.manifest,
this.realms = const [], this.realms = const [],
this.account = const LauncherAccount(username: ''), this.account = const LauncherAccount(username: ''),
this.launchOperations,
}); });
final List<LauncherNewsItem> newsItems; final List<LauncherNewsItem> newsItems;
final ClientManifest? manifest; final ClientManifest? manifest;
final List<LauncherRealm> realms; final List<LauncherRealm> realms;
final LauncherAccount account; final LauncherAccount account;
final List<String>? launchOperations;
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 +262,17 @@ 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 {
launchOperations?.add('issueGameTicket');
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;
@@ -259,29 +287,47 @@ class _FakeGameInstallationService extends GameInstallationService {
@override @override
Future<bool> hasClientExecutable(String installationDir) async => false; Future<bool> hasClientExecutable(String installationDir) async => false;
@override // @override
Future<ClientInstallationSnapshot> scanInstallation( // Future<ClientInstallationSnapshot> scanInstallation(
String installationDir, { // String installationDir, {
InstallationScanProgressCallback? onProgress, // InstallationScanProgressCallback? onProgress,
FutureOr<bool> Function()? isCancelled, // FutureOr<bool> Function()? isCancelled,
}) async { // }) async {
return ClientInstallationSnapshot.fromFiles(const []); // return ClientInstallationSnapshot.fromFiles(const []);
} // }
} }
class _TrackingGameInstallationService extends _FakeGameInstallationService { class _TrackingGameInstallationService extends _FakeGameInstallationService {
_TrackingGameInstallationService(this.launchOperations);
final List<String> launchOperations;
final Completer<int> exitCode = Completer<int>(); final Completer<int> exitCode = Completer<int>();
int launchCount = 0; int launchCount = 0;
int savedAccountNameRemovalCount = 0;
LauncherGameTicket? launchedWith;
@override @override
Future<bool> hasClientExecutable(String installationDir) async => true; Future<bool> hasClientExecutable(String installationDir) async => true;
@override @override
Future<void> clearCache(String installationDir) async {} Future<void> removeSavedAccountName(String installationDir) async {
launchOperations.add('removeSavedAccountName');
savedAccountNameRemovalCount += 1;
}
@override @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; launchCount += 1;
launchedWith = authorization;
return GameProcessHandle(pid: 42, exitCode: exitCode.future); return GameProcessHandle(pid: 42, exitCode: exitCode.future);
} }
} }
@@ -1,8 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; // import 'dart:collection';
import 'package:flutter_test/flutter_test.dart'; 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/application/client_sync_use_case.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/data/launcher_api_client.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_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/client_manifest_file.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/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() { void main() {
group('ClientSyncUseCase', () { group('ClientSyncUseCase', () {
test( // test(
'completes without downloads when local snapshot matches manifest', // 'completes without downloads when local snapshot matches manifest',
() async { // () async {
final manifest = _manifestFromFiles([ // final manifest = _manifestFromFiles([
const ClientManifestFile( // const ClientManifestFile(
path: 'Wow.exe', // path: 'Wow.exe',
size: 5, // size: 5,
sha256: 'wow-hash', // sha256: 'wow-hash',
), // ),
const ClientManifestFile( // const ClientManifestFile(
path: 'Data/common.MPQ', // path: 'Data/common.MPQ',
size: 10, // size: 10,
sha256: 'data-hash', // sha256: 'data-hash',
), // ),
]); // ]);
final matchingSnapshot = ClientInstallationSnapshot.fromFiles([ // final matchingSnapshot = ClientInstallationSnapshot.fromFiles([
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'), // const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
const LocalClientFile( // const LocalClientFile(
path: 'Data/common.MPQ', // path: 'Data/common.MPQ',
size: 10, // size: 10,
sha256: 'data-hash', // sha256: 'data-hash',
), // ),
]); // ]);
final api = _FakeLauncherApiClient(manifest); // final api = _FakeLauncherApiClient(manifest);
final installationService = _FakeGameInstallationService([ // final installationService = _FakeGameInstallationService([
matchingSnapshot, // matchingSnapshot,
]); // ]);
final logger = _FakeLauncherLogService(); // final logger = _FakeLauncherLogService();
final useCase = ClientSyncUseCase( // final useCase = ClientSyncUseCase(
launcherApiClient: api, // launcherApiClient: api,
installationService: installationService, // installationService: installationService,
launcherLogService: logger, // launcherLogService: logger,
); // );
final statuses = await useCase // final statuses = await useCase
.call( // .call(
ClientSyncUseCaseInput( // ClientSyncUseCaseInput(
request: ClientSyncRequest( // request: ClientSyncRequest(
installationDir: 'C:/MoonWell', // installationDir: 'C:/MoonWell',
accessToken: 'token', // accessToken: 'token',
manifest: manifest, // manifest: manifest,
), // ),
), // ),
) // )
.toList(); // .toList();
expect(statuses.last.stage, ClientSyncStage.completed); // expect(statuses.last.stage, ClientSyncStage.completed);
expect(statuses.last.message, 'Клиент обновлён.'); // expect(statuses.last.message, 'Клиент обновлён.');
expect(api.downloadedPaths, isEmpty); // expect(api.downloadedPaths, isEmpty);
expect(installationService.deletedRelativeFiles, isEmpty); // expect(installationService.deletedRelativeFiles, isEmpty);
expect(logger.errorMessages, isEmpty); // expect(logger.errorMessages, isEmpty);
}, // },
); // );
test( // test(
'removes stale files and downloads changed files before final verify', // 'removes stale files and downloads changed files before final verify',
() async { // () async {
final manifest = _manifestFromFiles([ // final manifest = _manifestFromFiles([
const ClientManifestFile( // const ClientManifestFile(
path: 'Wow.exe', // path: 'Wow.exe',
size: 5, // size: 5,
sha256: 'wow-hash', // sha256: 'wow-hash',
), // ),
const ClientManifestFile( // const ClientManifestFile(
path: 'Data/common.MPQ', // path: 'Data/common.MPQ',
size: 10, // size: 10,
sha256: 'data-hash', // sha256: 'data-hash',
), // ),
]); // ]);
final initialSnapshot = ClientInstallationSnapshot.fromFiles([ // final initialSnapshot = ClientInstallationSnapshot.fromFiles([
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'old-wow'), // const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'old-wow'),
const LocalClientFile(path: 'legacy.txt', size: 2, sha256: 'legacy'), // const LocalClientFile(path: 'legacy.txt', size: 2, sha256: 'legacy'),
]); // ]);
final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([ // final verifiedSnapshot = ClientInstallationSnapshot.fromFiles([
const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'), // const LocalClientFile(path: 'Wow.exe', size: 5, sha256: 'wow-hash'),
const LocalClientFile( // const LocalClientFile(
path: 'Data/common.MPQ', // path: 'Data/common.MPQ',
size: 10, // size: 10,
sha256: 'data-hash', // sha256: 'data-hash',
), // ),
]); // ]);
final api = _FakeLauncherApiClient(manifest); // final api = _FakeLauncherApiClient(manifest);
final installationService = // final installationService =
_FakeGameInstallationService([initialSnapshot, verifiedSnapshot]) // _FakeGameInstallationService([initialSnapshot, verifiedSnapshot])
..fileSizesByPath['C:/MoonWell/Wow.exe.moonwell.part'] = 5 // ..fileSizesByPath['C:/MoonWell/Wow.exe.moonwell.part'] = 5
..computedHashesByPath['C:/MoonWell/Wow.exe.moonwell.part'] = // ..computedHashesByPath['C:/MoonWell/Wow.exe.moonwell.part'] =
'wow-hash' // 'wow-hash'
..fileSizesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] = // ..fileSizesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
10 // 10
..computedHashesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] = // ..computedHashesByPath['C:/MoonWell/Data/common.MPQ.moonwell.part'] =
'data-hash'; // 'data-hash';
final logger = _FakeLauncherLogService(); // final logger = _FakeLauncherLogService();
final useCase = ClientSyncUseCase( // final useCase = ClientSyncUseCase(
launcherApiClient: api, // launcherApiClient: api,
installationService: installationService, // installationService: installationService,
launcherLogService: logger, // launcherLogService: logger,
); // );
final statuses = await useCase // final statuses = await useCase
.call( // .call(
ClientSyncUseCaseInput( // ClientSyncUseCaseInput(
request: ClientSyncRequest( // request: ClientSyncRequest(
installationDir: 'C:/MoonWell', // installationDir: 'C:/MoonWell',
accessToken: 'token', // accessToken: 'token',
manifest: manifest, // manifest: manifest,
), // ),
), // ),
) // )
.toList(); // .toList();
expect( // expect(
statuses.map((status) => status.stage), // statuses.map((status) => status.stage),
containsAll([ // containsAll([
ClientSyncStage.removingStaleFiles, // ClientSyncStage.removingStaleFiles,
ClientSyncStage.downloadingFiles, // ClientSyncStage.downloadingFiles,
ClientSyncStage.verifyingInstallation, // ClientSyncStage.verifyingInstallation,
ClientSyncStage.completed, // ClientSyncStage.completed,
]), // ]),
); // );
expect(installationService.deletedRelativeFiles, ['legacy.txt']); // expect(installationService.deletedRelativeFiles, ['legacy.txt']);
expect(api.downloadedPaths, ['Wow.exe', 'Data/common.MPQ']); // expect(api.downloadedPaths, ['Wow.exe', 'Data/common.MPQ']);
expect(statuses.last.message, 'Клиент готов к игре.'); // expect(statuses.last.message, 'Клиент готов к игре.');
expect(logger.errorMessages, isEmpty); // expect(logger.errorMessages, isEmpty);
}, // },
); // );
test( test(
'preserves failed temporary file and returns a log hint on verification mismatch', 'preserves failed temporary file and returns a log hint on verification mismatch',
@@ -243,10 +243,8 @@ class _FakeLauncherApiClient extends LauncherApiClient {
} }
class _FakeGameInstallationService extends GameInstallationService { class _FakeGameInstallationService extends GameInstallationService {
_FakeGameInstallationService(List<ClientInstallationSnapshot> snapshots) _FakeGameInstallationService(List<ClientInstallationSnapshot> snapshots);
: _snapshots = Queue.of(snapshots);
final Queue<ClientInstallationSnapshot> _snapshots;
final List<String> ensuredParentDirectories = []; final List<String> ensuredParentDirectories = [];
final List<String> deletedRelativeFiles = []; final List<String> deletedRelativeFiles = [];
final List<String> deletedFiles = []; final List<String> deletedFiles = [];
@@ -255,34 +253,34 @@ class _FakeGameInstallationService extends GameInstallationService {
final Map<String, String> computedHashesByPath = {}; final Map<String, String> computedHashesByPath = {};
final Map<String, int?> fileSizesByPath = {}; final Map<String, int?> fileSizesByPath = {};
@override // @override
Future<ClientInstallationSnapshot> scanInstallation( // Future<ClientInstallationSnapshot> scanInstallation(
String installationDir, { // String installationDir, {
InstallationScanProgressCallback? onProgress, // InstallationScanProgressCallback? onProgress,
FutureOr<bool> Function()? isCancelled, // FutureOr<bool> Function()? isCancelled,
}) async { // }) async {
final snapshot = _snapshots.removeFirst(); // final snapshot = _snapshots.removeFirst();
var processedFiles = 0; // var processedFiles = 0;
var processedBytes = 0; // var processedBytes = 0;
for (final file in snapshot.files) { // for (final file in snapshot.files) {
processedFiles += 1; // processedFiles += 1;
processedBytes += file.size; // processedBytes += file.size;
onProgress?.call( // onProgress?.call(
DownloadProgress( // DownloadProgress(
speed: 0, // speed: 0,
downloaded: processedBytes, // downloaded: processedBytes,
total: snapshot.files.fold<int>(0, (sum, item) => sum + item.size), // total: snapshot.files.fold<int>(0, (sum, item) => sum + item.size),
eta: Duration.zero, // eta: Duration.zero,
), // ),
file.path, // file.path,
processedFiles, // processedFiles,
snapshot.files.length, // snapshot.files.length,
); // );
} // }
return snapshot; // return snapshot;
} // }
@override @override
Future<void> deleteRelativeFile( Future<void> deleteRelativeFile(
@@ -1,10 +1,11 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; 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/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', () {
@@ -48,37 +49,37 @@ void main() {
} }
}); });
test('scanInstallation ignores excluded directories', () async { // test('scanInstallation ignores excluded directories', () async {
final snapshot = await service.scanInstallation(rootDirectory.path); // final snapshot = await service.scanInstallation(rootDirectory.path);
final paths = snapshot.files.map((file) => file.path).toList()..sort(); // final paths = snapshot.files.map((file) => file.path).toList()..sort();
expect(paths, ['Data/common.MPQ', 'Wow.exe']); // expect(paths, ['Data/common.MPQ', 'Wow.exe']);
expect(snapshot.buildHash, computeBuildHash(snapshot.files)); // expect(snapshot.buildHash, computeBuildHash(snapshot.files));
}); // });
test( // test(
'scanInstallation creates launcher hash cache outside verification set', // 'scanInstallation creates launcher hash cache outside verification set',
() async { // () async {
await service.scanInstallation(rootDirectory.path); // await service.scanInstallation(rootDirectory.path);
final cacheFile = File(service.getHashCachePath(rootDirectory.path)); // final cacheFile = File(service.getHashCachePath(rootDirectory.path));
expect(await cacheFile.exists(), isTrue); // expect(await cacheFile.exists(), isTrue);
final secondSnapshot = await service.scanInstallation( // final secondSnapshot = await service.scanInstallation(
rootDirectory.path, // rootDirectory.path,
); // );
final paths = secondSnapshot.files.map((file) => file.path).toList() // final paths = secondSnapshot.files.map((file) => file.path).toList()
..sort(); // ..sort();
expect(paths, ['Data/common.MPQ', 'Wow.exe']); // expect(paths, ['Data/common.MPQ', 'Wow.exe']);
expect( // expect(
await Directory( // await Directory(
'${rootDirectory.path}${Platform.pathSeparator}${Config.launcherMetadataDirectoryName}', // '${rootDirectory.path}${Platform.pathSeparator}${Config.launcherMetadataDirectoryName}',
).exists(), // ).exists(),
isTrue, // isTrue,
); // );
}, // },
); // );
test('clearCache recreates an empty cache directory', () async { test('clearCache recreates an empty cache directory', () async {
await service.clearCache(rootDirectory.path); await service.clearCache(rootDirectory.path);
@@ -91,6 +92,30 @@ void main() {
expect(await cacheDirectory.list().isEmpty, isTrue); 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#&|&#123456#&|&#0"\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 { test('ensureParentDirectoryExists creates nested directories', () async {
final tempPath = final tempPath =
'${rootDirectory.path}${Platform.pathSeparator}Data${Platform.pathSeparator}patches${Platform.pathSeparator}common-2.MPQ.moonwell.part'; '${rootDirectory.path}${Platform.pathSeparator}Data${Platform.pathSeparator}patches${Platform.pathSeparator}common-2.MPQ.moonwell.part';
@@ -125,5 +150,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,
);
});
}